use std::process::Command;
use anyhow::{bail, Context, Result};
const SKILL_ARG: &str = "/teamctl:adjust";
pub trait AdjustHost {
fn prompt_yes_no(&self, question: &str) -> Result<bool>;
fn exec_claude(&self, args: &[&str]) -> Result<()>;
}
pub fn run(yes: bool) -> Result<()> {
if yes {
bail!(
"`teamctl adjust` is interactive-only and incompatible with `--yes`. \
Drop `--yes` to run the flow; the skill itself collects what it needs."
);
}
let host = RealHost;
run_with(&host)
}
pub fn run_with(host: &dyn AdjustHost) -> Result<()> {
let go = host.prompt_yes_no(
"This will open Claude Code and run `/teamctl:adjust` to help you evolve your team. \
Continue? [Y/n] ",
)?;
if !go {
return Ok(());
}
host.exec_claude(&[SKILL_ARG])
}
fn answer_is_yes(line: &str) -> bool {
let s = line.trim().to_lowercase();
s.is_empty() || s == "y" || s == "yes"
}
struct RealHost;
impl AdjustHost for RealHost {
fn prompt_yes_no(&self, question: &str) -> Result<bool> {
use std::io::{stderr, stdin, Write};
let mut stderr = stderr();
write!(stderr, "{question}").ok();
stderr.flush().ok();
let mut line = String::new();
stdin()
.read_line(&mut line)
.context("read prompt response")?;
Ok(answer_is_yes(&line))
}
fn exec_claude(&self, args: &[&str]) -> Result<()> {
let status = Command::new("claude")
.args(args)
.status()
.with_context(|| {
"failed to launch `claude` — is Claude Code installed and on PATH? See \
https://code.claude.com/docs"
})?;
if !status.success() {
bail!(
"`claude {}` exited with status {status} — see the Claude Code output above \
for details.",
args.join(" ")
);
}
Ok(())
}
}
#[cfg(test)]
pub mod test_support {
use super::*;
use std::cell::RefCell;
pub struct MockHost {
pub answer: bool,
pub exec_calls: RefCell<Vec<Vec<String>>>,
pub prompt_calls: RefCell<u32>,
}
impl MockHost {
pub fn new() -> Self {
Self {
answer: false,
exec_calls: RefCell::new(Vec::new()),
prompt_calls: RefCell::new(0),
}
}
pub fn with_answer(mut self, ans: bool) -> Self {
self.answer = ans;
self
}
}
impl AdjustHost for MockHost {
fn prompt_yes_no(&self, _q: &str) -> Result<bool> {
*self.prompt_calls.borrow_mut() += 1;
Ok(self.answer)
}
fn exec_claude(&self, args: &[&str]) -> Result<()> {
self.exec_calls
.borrow_mut()
.push(args.iter().map(|s| s.to_string()).collect());
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::MockHost;
use super::*;
#[test]
fn y_execs_claude_with_skill_arg_only() {
let host = MockHost::new().with_answer(true);
run_with(&host).unwrap();
let calls = host.exec_calls.borrow();
assert_eq!(calls.len(), 1, "single exec on accept");
assert_eq!(
calls[0],
vec![SKILL_ARG.to_string()],
"argv must be exactly the skill invocation"
);
assert_eq!(*host.prompt_calls.borrow(), 1);
}
#[test]
fn n_exits_clean_without_exec() {
let host = MockHost::new().with_answer(false);
run_with(&host).expect("decline must exit cleanly, not error");
assert!(host.exec_calls.borrow().is_empty());
assert_eq!(*host.prompt_calls.borrow(), 1);
}
#[test]
fn empty_input_execs_claude() {
let host = MockHost::new().with_answer(answer_is_yes(""));
run_with(&host).unwrap();
assert_eq!(
*host.exec_calls.borrow(),
vec![vec![SKILL_ARG.to_string()]],
"empty Enter must open Claude Code"
);
assert_eq!(*host.prompt_calls.borrow(), 1);
}
#[test]
fn answer_is_yes_defaults_to_yes_on_empty() {
assert!(answer_is_yes(""));
assert!(answer_is_yes("\n"));
assert!(answer_is_yes(" "));
assert!(answer_is_yes("y"));
assert!(answer_is_yes("Y\n"));
assert!(answer_is_yes("yes"));
assert!(!answer_is_yes("n"));
assert!(!answer_is_yes("no"));
assert!(!answer_is_yes("N"));
}
#[test]
fn yes_flag_rejects_before_prompt() {
let err = run(true).expect_err("--yes must error");
let msg = format!("{err}");
assert!(msg.contains("interactive-only"), "msg: {msg}");
assert!(msg.contains("--yes"), "msg: {msg}");
}
}