1use std::ffi::OsStr;
17use std::io::Read;
18use std::path::{Path, PathBuf};
19use std::process::{Command, ExitStatus, Stdio};
20use std::sync::mpsc;
21use std::thread;
22use std::time::{Duration, Instant};
23
24use serde_json::{json, Value};
25
26use crate::adapter::which_all;
27
28#[path = "prime_site.rs"]
29pub mod site;
30
31pub const PRIMER: &str = include_str!("primer.md");
33
34pub const SCHEMA: &str = "stack-prime/1";
36
37pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(1000);
40
41pub const BRIEF_LINES: usize = 10;
43
44pub const READ_CAP: u64 = 64 * 1024;
46
47static SPAWN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
49
50pub struct Tool {
52 pub name: &'static str,
53 pub program: &'static str,
55 pub identity_argv: &'static [&'static str],
57 pub identity: &'static [&'static str],
60}
61
62pub const ROSTER: &[Tool] = &[
67 Tool {
68 name: "quipu",
69 program: "quipu",
70 identity_argv: &["--help"],
71 identity: &["knowledge graph"],
72 },
73 Tool {
74 name: "camayoc",
75 program: "camayoc",
76 identity_argv: &["--version"],
77 identity: &["camayoc"],
78 },
79 Tool {
80 name: "quechua",
81 program: "quechua",
82 identity_argv: &["--version"],
83 identity: &["quechua"],
84 },
85 Tool {
86 name: "chaski",
87 program: "chaski",
88 identity_argv: &["--help"],
89 identity: &["a post-commit and time-driven reactor"],
90 },
91 Tool {
92 name: "bobbin",
93 program: "bobbin",
94 identity_argv: &["--version"],
95 identity: &["bobbin"],
96 },
97 Tool {
98 name: "yupana",
99 program: "yupana",
100 identity_argv: &["--version"],
101 identity: &["yupana"],
102 },
103 Tool {
104 name: "desire-path",
105 program: "dp",
106 identity_argv: &["--help"],
107 identity: &["failed AI tool calls"],
108 },
109 Tool {
110 name: "seeds",
111 program: "sd",
112 identity_argv: &["--help"],
113 identity: &[
114 "seeds is the beads-compatible tracker",
115 "seeds is the work-item tracker for the quipu stack",
116 ],
117 },
118 Tool {
119 name: "shantytown",
120 program: "st",
121 identity_argv: &["--help"],
122 identity: &["sling"],
123 },
124 Tool {
125 name: "shuttle",
126 program: "shuttle",
127 identity_argv: &["--help"],
128 identity: &["The `shuttle` CLI"],
129 },
130];
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum Outcome {
135 NotInstalled,
136 Foreign,
138 Primed(Vec<String>),
139 NoPrime,
140 TimedOut,
141}
142
143impl Outcome {
144 fn key(&self) -> &'static str {
145 match self {
146 Self::NotInstalled => "not-installed",
147 Self::Foreign => "foreign",
148 Self::Primed(_) => "primed",
149 Self::NoPrime => "no-prime",
150 Self::TimedOut => "timed-out",
151 }
152 }
153}
154
155#[derive(Debug, Clone)]
156pub struct Probe {
157 pub tool: &'static str,
158 pub program: Option<PathBuf>,
159 pub outcome: Outcome,
160 pub elapsed_ms: u128,
161}
162
163fn split(primer: &str) -> Vec<(&str, &str)> {
165 let mut starts = vec![(0usize, "")];
166 let mut offset = 0;
167 for line in primer.split_inclusive('\n') {
168 if let Some(h) = line.strip_prefix("## ") {
169 starts.push((offset, h.trim_end()));
170 }
171 offset += line.len();
172 }
173 starts
174 .iter()
175 .enumerate()
176 .map(|(i, &(start, heading))| {
177 let end = starts.get(i + 1).map_or(primer.len(), |s| s.0);
178 (heading, &primer[start..end])
179 })
180 .collect()
181}
182
183pub fn sections(primer: &str) -> Vec<&str> {
185 split(primer).into_iter().skip(1).map(|(h, _)| h).collect()
186}
187
188pub fn section(primer: &str, wanted: &str) -> Result<String, String> {
190 let needle = wanted.trim().to_lowercase();
191 split(primer)
192 .into_iter()
193 .skip(1)
194 .find(|(h, _)| h.to_lowercase().starts_with(&needle))
195 .map(|(_, body)| body.to_string())
196 .ok_or_else(|| {
197 format!(
198 "unknown primer section: {wanted}. Sections: {}",
199 sections(primer).join(", ")
200 )
201 })
202}
203
204#[cfg(unix)]
206fn kill_group(child: &mut std::process::Child) {
207 extern "C" {
208 fn kill(pid: i32, sig: i32) -> i32;
209 }
210 if let Ok(pid) = i32::try_from(child.id()) {
211 unsafe {
213 kill(-pid, 9);
214 }
215 }
216 let _ = child.kill();
217 let _ = child.wait();
218}
219
220#[cfg(not(unix))]
221fn kill_group(child: &mut std::process::Child) {
222 let _ = child.kill();
223 let _ = child.wait();
224}
225
226fn run_bounded(
231 program: &Path,
232 args: &[&str],
233 home: Option<&Path>,
234 stderr: bool,
235 deadline: Instant,
236) -> Option<Result<(ExitStatus, Vec<u8>), ()>> {
237 let mut cmd = Command::new(program);
238 cmd.args(args)
239 .stdin(Stdio::null())
240 .stdout(Stdio::piped())
241 .stderr(if stderr {
242 Stdio::piped()
243 } else {
244 Stdio::null()
245 });
246 if let Some(home) = home {
247 cmd.env("HOME", home);
248 }
249 #[cfg(unix)]
250 std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
251 let spawned = {
255 let _guard = SPAWN_LOCK.lock().unwrap_or_else(|e| e.into_inner());
256 cmd.spawn()
257 };
258 let Ok(mut child) = spawned else {
259 return Some(Err(()));
260 };
261 let readers: Vec<mpsc::Receiver<Vec<u8>>> = [
263 child
264 .stdout
265 .take()
266 .map(|o| Box::new(o) as Box<dyn Read + Send>),
267 child
268 .stderr
269 .take()
270 .map(|e| Box::new(e) as Box<dyn Read + Send>),
271 ]
272 .into_iter()
273 .flatten()
274 .map(|pipe| {
275 let (tx, rx) = mpsc::channel();
276 thread::spawn(move || {
277 let mut buf = Vec::new();
278 let _ = pipe.take(READ_CAP).read_to_end(&mut buf);
279 let _ = tx.send(buf);
280 });
281 rx
282 })
283 .collect();
284 let status = loop {
285 match child.try_wait() {
286 Ok(Some(status)) => break status,
287 Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(5)),
288 _ => {
289 kill_group(&mut child);
290 return None;
291 }
292 }
293 };
294 let mut bytes = Vec::new();
295 for rx in readers {
296 let remaining = deadline.saturating_duration_since(Instant::now());
297 match rx.recv_timeout(remaining.max(Duration::from_millis(20))) {
298 Ok(part) => bytes.extend(part),
299 Err(_) => {
300 kill_group(&mut child);
303 return Some(Ok((status, bytes)));
304 }
305 }
306 }
307 Some(Ok((status, bytes)))
308}
309
310fn identify(tool: &Tool, path: &Path, deadline: Instant) -> Option<bool> {
313 let home = tempfile::tempdir().ok()?;
314 let (_, bytes) = match run_bounded(path, tool.identity_argv, Some(home.path()), true, deadline)?
315 {
316 Ok(out) => out,
317 Err(()) => return Some(false),
318 };
319 let text = String::from_utf8_lossy(&bytes);
320 Some(tool.identity.iter().any(|marker| text.contains(marker)))
321}
322
323fn probe_one(tool: &Tool, path: &Path, timeout: Duration) -> Outcome {
325 let deadline = Instant::now() + timeout;
326 match identify(tool, path, deadline) {
327 None => return Outcome::TimedOut,
328 Some(false) => return Outcome::Foreign,
329 Some(true) => {}
330 }
331 let (status, bytes) = match run_bounded(path, &["prime", "--brief"], None, false, deadline) {
332 None => return Outcome::TimedOut,
333 Some(Err(())) => return Outcome::NoPrime,
334 Some(Ok(out)) => out,
335 };
336 let lines: Vec<String> = String::from_utf8_lossy(&bytes)
337 .lines()
338 .map(str::trim_end)
339 .filter(|l| !l.is_empty())
340 .map(str::to_owned)
341 .collect();
342 if !status.success() || lines.is_empty() {
343 return Outcome::NoPrime;
344 }
345 Outcome::Primed(lines)
346}
347
348pub fn probe(path: Option<&OsStr>, timeout: Duration) -> Vec<Probe> {
350 let handles: Vec<_> = ROSTER
351 .iter()
352 .map(|tool| {
353 let program = which_all(tool.program, path).into_iter().next();
354 thread::spawn(move || {
355 let started = Instant::now();
356 let outcome = match &program {
357 None => Outcome::NotInstalled,
358 Some(p) => probe_one(tool, p, timeout),
359 };
360 Probe {
361 tool: tool.name,
362 program,
363 outcome,
364 elapsed_ms: started.elapsed().as_millis(),
365 }
366 })
367 })
368 .collect();
369 handles
370 .into_iter()
371 .zip(ROSTER)
372 .map(|(h, tool)| {
373 h.join().unwrap_or(Probe {
374 tool: tool.name,
375 program: None,
376 outcome: Outcome::NoPrime,
377 elapsed_ms: 0,
378 })
379 })
380 .collect()
381}
382
383fn names(probes: &[Probe], key: &str) -> Vec<&'static str> {
384 probes
385 .iter()
386 .filter(|p| p.outcome.key() == key)
387 .map(|p| p.tool)
388 .collect()
389}
390
391fn installed_count(probes: &[Probe]) -> usize {
392 probes
393 .iter()
394 .filter(|p| p.outcome != Outcome::NotInstalled)
395 .count()
396}
397
398fn capped(tool: &str, lines: &[String]) -> Vec<String> {
400 if lines.len() <= BRIEF_LINES {
401 return lines.to_vec();
402 }
403 let mut out = lines[..BRIEF_LINES - 1].to_vec();
404 out.push(format!(
405 "... {} more lines; run `{tool} prime --brief`",
406 lines.len() + 1 - BRIEF_LINES
407 ));
408 out
409}
410
411pub fn render_live(probes: &[Probe]) -> String {
413 let mut out = String::from("\n## Live state\n\n");
414 out.push_str(&format!(
415 "Stack tools on PATH: {} of {}.\n",
416 installed_count(probes),
417 probes.len()
418 ));
419 for p in probes {
420 let program = p
421 .program
422 .as_ref()
423 .map(|path| path.display().to_string())
424 .unwrap_or_default();
425 match &p.outcome {
426 Outcome::NotInstalled => {}
427 Outcome::Primed(lines) => {
428 out.push_str(&format!("\n### {} (`{program}`)\n\n", p.tool));
429 for line in capped(p.tool, lines) {
430 out.push_str(&format!(" {line}\n"));
431 }
432 }
433 Outcome::NoPrime => {
434 out.push_str(&format!("\n### {} (`{program}`)\n\nno prime yet\n", p.tool))
435 }
436 Outcome::Foreign => out.push_str(&format!(
437 "\n### {} (`{program}`)\n\nnot the stack tool (a different program with that name); not run\n",
438 p.tool
439 )),
440 Outcome::TimedOut => out.push_str(&format!(
441 "\n### {} (`{program}`)\n\ntimed out; run `{program} prime` directly\n",
442 p.tool
443 )),
444 }
445 }
446 let missing = names(probes, "not-installed");
447 if !missing.is_empty() {
448 out.push_str(&format!("\nNot installed: {}.\n", missing.join(", ")));
449 }
450 out
451}
452
453pub fn render_static_brief() -> String {
455 let what = section(PRIMER, "what").unwrap_or_default();
456 let mut out = format!(
457 "tambo {}: installs and proves the Quipu stack\n",
458 env!("CARGO_PKG_VERSION")
459 );
460 for line in what.lines().skip(1).filter(|l| !l.trim().is_empty()) {
461 out.push_str(line);
462 out.push('\n');
463 }
464 out.push_str("Run `tambo prime` for the full primer.\n");
465 out
466}
467
468pub fn render_brief(probes: &[Probe]) -> String {
470 let list = |key| {
471 let n = names(probes, key);
472 if n.is_empty() {
473 "none".to_owned()
474 } else {
475 n.join(", ")
476 }
477 };
478 let mut out = format!(
479 "tambo {}: installs and proves the Quipu stack\n",
480 env!("CARGO_PKG_VERSION")
481 );
482 out.push_str(&format!(
483 "Stack tools on PATH: {} of {}\n",
484 installed_count(probes),
485 probes.len()
486 ));
487 out.push_str(&format!("Primed: {}\n", list("primed")));
488 out.push_str(&format!("No prime yet: {}\n", list("no-prime")));
489 let timed_out = names(probes, "timed-out");
490 if !timed_out.is_empty() {
491 out.push_str(&format!("Timed out: {}\n", timed_out.join(", ")));
492 }
493 let foreign = names(probes, "foreign");
494 if !foreign.is_empty() {
495 out.push_str(&format!("Not the stack tool: {}\n", foreign.join(", ")));
496 }
497 out.push_str(&format!("Not installed: {}\n", list("not-installed")));
498 out.push_str("Run `tambo prime` for the full primer.\n");
499 out
500}
501
502pub fn to_json(primer_text: Option<&str>, probes: Option<&[Probe]>, elapsed_ms: u128) -> Value {
504 let mut primer = json!({ "sections": sections(PRIMER) });
505 if let Some(text) = primer_text {
506 primer["text"] = json!(text);
507 }
508 let live = match probes {
509 None => json!({ "status": "skipped" }),
510 Some(probes) => {
511 let tools: Vec<Value> = probes
512 .iter()
513 .map(|p| {
514 let mut v = json!({
515 "tool": p.tool,
516 "status": p.outcome.key(),
517 "program": p.program.as_ref().map(|x| x.display().to_string()),
518 "elapsed_ms": p.elapsed_ms,
519 });
520 if let Outcome::Primed(lines) = &p.outcome {
521 v["brief"] = json!(capped(p.tool, lines));
522 }
523 v
524 })
525 .collect();
526 let partial = probes.iter().any(|p| p.outcome == Outcome::TimedOut);
527 json!({
528 "status": if partial { "partial" } else { "ok" },
529 "elapsed_ms": elapsed_ms,
530 "summary": { "tools": tools },
531 })
532 }
533 };
534 let status = if matches!(live["status"].as_str(), Some("ok" | "skipped")) {
535 "ok"
536 } else {
537 "degraded"
538 };
539 json!({
540 "schema": SCHEMA,
541 "tool": "tambo",
542 "version": env!("CARGO_PKG_VERSION"),
543 "primer": primer,
544 "live": live,
545 "status": status,
546 })
547}
548
549pub struct Options<'a> {
550 pub brief: bool,
551 pub json: bool,
552 pub offline: bool,
553 pub section: Option<&'a str>,
554 pub timeout: Duration,
555 pub rikuy_url: Option<&'a str>,
556}
557
558pub fn run(opts: &Options) -> Result<String, String> {
560 let text = match opts.section {
561 Some(wanted) => section(PRIMER, wanted)?,
562 None => PRIMER.to_owned(),
563 };
564 let started = Instant::now();
565 let path = std::env::var_os("PATH");
566 let rikuy_url = opts
567 .rikuy_url
568 .map(str::to_owned)
569 .or_else(|| std::env::var("RIKUY_URL").ok());
570 let site_handle = (!opts.offline).then(|| {
571 let path = path.clone();
572 let timeout = opts.timeout;
573 thread::spawn(move || site::probe(rikuy_url.as_deref(), path.as_deref(), timeout))
574 });
575 let probes = (!opts.offline).then(|| probe(path.as_deref(), opts.timeout));
576 let site = site_handle.map(|h| {
577 h.join().unwrap_or(site::Site {
578 url: None,
579 status: "unreadable",
580 reachable: None,
581 generated_at: None,
582 revision: None,
583 reason: "reader_failed",
584 elapsed_ms: 0,
585 })
586 });
587 let elapsed = started.elapsed().as_millis();
588 if opts.json {
589 let primer_text = (!opts.brief).then_some(text.as_str());
590 let mut value = to_json(primer_text, probes.as_deref(), elapsed);
591 if let Some(site) = &site {
592 value["live"]["summary"]["sites"] = json!([site.value()]);
593 if site.reason != "url_not_configured" && site.status != "ok" {
594 if value["live"]["status"] != "partial" {
597 value["live"]["status"] = json!(site.status);
598 }
599 value["status"] = json!("degraded");
600 }
601 }
602 return Ok(format!(
603 "{}\n",
604 serde_json::to_string_pretty(&value).unwrap_or_default()
605 ));
606 }
607 if opts.brief {
608 return Ok(match &probes {
609 Some(probes) => {
610 let mut brief = render_brief(probes);
611 if let Some(site) = &site {
612 let end = "Run `tambo prime` for the full primer.\n";
613 brief = brief.replace(end, &format!("{}\n{end}", site.line()));
614 }
615 brief
616 }
617 None => render_static_brief(),
618 });
619 }
620 let mut out = text;
621 if let Some(probes) = &probes {
622 out.push_str(&render_live(probes));
623 if let Some(site) = &site {
624 out.push_str(&format!("\n### rikuy (static site)\n\n{}\n", site.line()));
625 out.push_str(&format!(
626 "Read: {} ({}); live brief state not measured.\n",
627 site.status, site.reason
628 ));
629 }
630 }
631 Ok(out)
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn primer_has_the_contract_sections_in_order() {
640 assert!(PRIMER.starts_with("# Tambo agent primer\n"));
641 let s = sections(PRIMER);
642 let required = [
643 "What Tambo is",
644 "Agent workflow",
645 "Core commands",
646 "MCP tools",
647 "Next steps",
648 ];
649 let positions: Vec<usize> = required
650 .iter()
651 .map(|r| {
652 s.iter()
653 .position(|h| h == r)
654 .unwrap_or_else(|| panic!("missing {r}"))
655 })
656 .collect();
657 assert!(positions.windows(2).all(|w| w[0] < w[1]), "{s:?}");
658 assert_eq!(s.last(), Some(&"Next steps"));
659 }
660
661 #[test]
662 fn section_matches_a_case_insensitive_prefix() {
663 assert!(section(PRIMER, "mcp").unwrap().starts_with("## MCP tools"));
664 assert!(section(PRIMER, "nope").unwrap_err().contains("Sections:"));
665 }
666
667 #[test]
668 fn static_brief_is_five_to_ten_lines() {
669 let n = render_static_brief().lines().count();
670 assert!((5..=10).contains(&n), "{n} lines");
671 }
672
673 #[test]
674 fn member_identities_match_their_manifests() {
675 for tool in ROSTER {
676 let Some(m) = crate::members::get(tool.name) else {
677 continue;
678 };
679 let p = m
680 .programs
681 .iter()
682 .find(|p| p.name == tool.program)
683 .unwrap_or_else(|| panic!("{}: no program {}", tool.name, tool.program));
684 let known: Vec<&str> = std::iter::once(&p.identity_contains)
685 .chain(&p.identity_alternatives)
686 .map(String::as_str)
687 .collect();
688 assert_eq!(
689 tool.identity_argv,
690 p.identity_argv.as_slice(),
691 "{}",
692 tool.name
693 );
694 assert_eq!(tool.identity, known.as_slice(), "{}", tool.name);
695 }
696 }
697
698 #[test]
699 fn roster_matches_the_stack_table() {
700 let table = include_str!("../docs/stack-table.md");
701 let listed: Vec<&str> = table
702 .lines()
703 .filter_map(|l| l.strip_prefix("| ["))
704 .filter_map(|l| l.split(']').next())
705 .filter(|name| !matches!(*name, "tambo" | "rikuy"))
706 .collect();
707 let roster: Vec<&str> = ROSTER.iter().map(|t| t.name).collect();
708 assert_eq!(roster, listed);
709 }
710
711 #[test]
712 fn roster_programs_agree_with_the_adapters() {
713 for tool in ROSTER {
714 assert_ne!(tool.program, "qp", "qp is an alias, never probed");
715 let Some(name) = crate::model::ToolName::parse(tool.name) else {
716 continue;
717 };
718 let known = crate::adapter::programs(name);
719 if !known.is_empty() {
720 assert!(known.contains(&tool.program), "{}: {known:?}", tool.name);
721 }
722 }
723 }
724
725 #[test]
726 fn long_briefs_are_capped() {
727 let lines: Vec<String> = (0..25).map(|i| format!("line {i}")).collect();
728 let out = capped("quipu", &lines);
729 assert_eq!(out.len(), BRIEF_LINES);
730 assert!(out.last().unwrap().contains("16 more lines"));
731 }
732
733 #[test]
734 fn brief_stays_within_ten_lines() {
735 let probes: Vec<Probe> = ROSTER
736 .iter()
737 .enumerate()
738 .map(|(i, tool)| Probe {
739 tool: tool.name,
740 program: Some(PathBuf::from(format!("/bin/{}", tool.program))),
741 outcome: match i % 5 {
742 0 => Outcome::Primed(vec!["x".into()]),
743 1 => Outcome::NoPrime,
744 2 => Outcome::TimedOut,
745 3 => Outcome::Foreign,
746 _ => Outcome::NotInstalled,
747 },
748 elapsed_ms: 1,
749 })
750 .collect();
751 let n = render_brief(&probes).lines().count();
752 assert!((5..=10).contains(&n), "{n} lines");
753 let v = to_json(None, Some(&probes), 5);
754 assert_eq!(v["live"]["status"], "partial");
755 assert_eq!(v["status"], "degraded");
756 assert_eq!(v["schema"], SCHEMA);
757 assert!(v["primer"].get("text").is_none());
758 }
759}