Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ jobs = 16
# on machines without sccache installed.

[target.x86_64-unknown-linux-gnu]
linker = "clang"
linker = "gcc"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
9 changes: 9 additions & 0 deletions cas-cli/src/cli/factory/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ pub(super) fn execute_daemon(
},
teams_configs,
lead_session_id: Some(lead_session_id),
minions_theme: cas_config
.theme
.as_ref()
.map(|t| t.variant == crate::ui::theme::ThemeVariant::Minions)
.unwrap_or(false),
};

let daemon_config = DaemonConfig {
Expand Down Expand Up @@ -127,6 +132,7 @@ pub(super) fn run_factory_with_daemon(
.unwrap_or_else(|| "supervisor".to_string());
let worker_names = config.worker_names.clone();
let worktrees_enabled = config.enable_worktrees;
let minions_theme = config.minions_theme;
let cwd = config.cwd.to_string_lossy().to_string();
let profile = build_boot_profile(&config, worker_names.len());

Expand All @@ -148,6 +154,7 @@ pub(super) fn run_factory_with_daemon(
session_name: session_name.clone(),
profile,
skip_animation: false,
minions_theme,
};

if let Err(e) = run_boot_screen_client(&boot_config, &sock_path, 0) {
Expand Down Expand Up @@ -177,6 +184,7 @@ pub(super) fn run_factory_with_daemon(
.unwrap_or_else(|| "supervisor".to_string());
let worker_names = config.worker_names.clone();
let worktrees_enabled = config.enable_worktrees;
let minions_theme = config.minions_theme;
let cwd = config.cwd.to_string_lossy().to_string();
let profile = build_boot_profile(&config, worker_names.len());

Expand All @@ -199,6 +207,7 @@ pub(super) fn run_factory_with_daemon(
session_name: session_name.clone(),
profile,
skip_animation: false,
minions_theme,
};

if let Err(e) = run_boot_screen_client(&boot_config, &sock_path, daemon_pid) {
Expand Down
57 changes: 54 additions & 3 deletions cas-cli/src/cli/factory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,27 @@ pub fn execute(args: &FactoryArgs, cli: &Cli, cas_root: Option<&std::path::Path>
}
}

let all_names = generate_unique(args.workers as usize + 1);
let supervisor_name = all_names[0].clone();
let worker_names: Vec<String> = all_names[1..].to_vec();
// Determine theme variant early so we can use themed names
let theme_variant = {
let cd = cwd.join(".cas");
let cr = cas_root.or_else(|| if cd.exists() { Some(cd.as_path()) } else { None });
cr.and_then(|r| Config::load(r).ok())
.and_then(|c| c.theme.as_ref().map(|t| t.variant))
.unwrap_or_default()
};
let is_minions = theme_variant == crate::ui::theme::ThemeVariant::Minions;

let (supervisor_name, worker_names) = if is_minions {
use crate::orchestration::names::{generate_minion_supervisor, generate_minion_unique};
let sup = generate_minion_supervisor();
let workers = generate_minion_unique(args.workers as usize);
(sup, workers)
} else {
let all_names = generate_unique(args.workers as usize + 1);
let sup = all_names[0].clone();
let workers: Vec<String> = all_names[1..].to_vec();
(sup, workers)
};

let session_name = args
.name
Expand Down Expand Up @@ -613,6 +631,7 @@ pub fn execute(args: &FactoryArgs, cli: &Cli, cas_root: Option<&std::path::Path>
},
teams_configs,
lead_session_id: Some(lead_session_id),
minions_theme: is_minions,
};

let phone_home = !args.no_phone_home;
Expand Down Expand Up @@ -826,6 +845,7 @@ fn preflight_factory_launch(
let mut missing_git_repo = false;
let mut missing_initial_commit = false;
let mut missing_claude_commit = false;
let mut missing_mcp_commit = false;

let resolved_cas_root = match validate_cas_root(cwd, cas_root) {
Ok(path) => Some(path),
Expand Down Expand Up @@ -961,6 +981,33 @@ fn preflight_factory_launch(
}
}

// Check if .mcp.json is committed (required for worktree-based workers)
if enable_worktrees && !missing_git_repo && !missing_initial_commit {
let mcp_tracked = std::process::Command::new("git")
.args(["ls-files", "--error-unmatch", ".mcp.json"])
.current_dir(cwd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);

if !mcp_tracked {
if args.workers > 0 {
failures.push(
".mcp.json is not committed. Workers need it for MCP tool access in their worktrees."
.to_string(),
);
missing_mcp_commit = true;
} else {
notices.push(
".mcp.json is not committed. Commit it before spawning workers: git add .mcp.json && git commit -m \"Configure CAS MCP\""
.to_string(),
);
}
}
}

if !failures.is_empty() {
let details = failures
.iter()
Expand All @@ -986,6 +1033,10 @@ fn preflight_factory_launch(
steps.push("git add .claude/ CLAUDE.md .mcp.json .gitignore".to_string());
steps.push("git commit -m \"Configure CAS\"".to_string());
}
if missing_mcp_commit && !missing_claude_commit {
steps.push("git add .mcp.json".to_string());
steps.push("git commit -m \"Configure CAS MCP\"".to_string());
}
let launch = if args.no_worktrees {
"cas factory --no-worktrees"
} else {
Expand Down
23 changes: 22 additions & 1 deletion cas-cli/src/cli/hook/config_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,12 @@ pub(crate) fn get_cas_hooks_config(config: &crate::config::HookConfig) -> serde_
);
}

let mut allow_permissions = get_cas_bash_permissions();
allow_permissions.extend(get_cas_mcp_permissions());

serde_json::json!({
"permissions": {
"allow": get_cas_bash_permissions()
"allow": allow_permissions
},
"hooks": hooks,
"statusLine": {
Expand All @@ -257,6 +260,24 @@ pub fn get_cas_bash_permissions() -> Vec<String> {
]
}

/// Get MCP tool permission patterns for CAS tools
///
/// Workers need these permissions to call mcp__cas__* tools without prompts.
pub fn get_cas_mcp_permissions() -> Vec<String> {
vec![
"mcp__cas__task".to_string(),
"mcp__cas__coordination".to_string(),
"mcp__cas__memory".to_string(),
"mcp__cas__search".to_string(),
"mcp__cas__rule".to_string(),
"mcp__cas__skill".to_string(),
"mcp__cas__spec".to_string(),
"mcp__cas__verification".to_string(),
"mcp__cas__system".to_string(),
"mcp__cas__pattern".to_string(),
]
}

/// Configure CAS as an MCP server via .mcp.json
///
/// Creates or updates .mcp.json in the project root to register CAS.
Expand Down
37 changes: 37 additions & 0 deletions cas-cli/src/cli/hook_tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ fn test_configure_creates_settings() {
allow_arr.iter().any(|v| v.as_str() == Some("Bash(cas :*)")),
"Bash(cas :*) permission missing"
);
// Verify CAS MCP tool permissions
assert!(
allow_arr
.iter()
.any(|v| v.as_str() == Some("mcp__cas__task")),
"mcp__cas__task permission missing"
);
assert!(
allow_arr
.iter()
.any(|v| v.as_str() == Some("mcp__cas__coordination")),
"mcp__cas__coordination permission missing"
);
assert!(
allow_arr
.iter()
.any(|v| v.as_str() == Some("mcp__cas__memory")),
"mcp__cas__memory permission missing"
);
assert!(
allow_arr
.iter()
.any(|v| v.as_str() == Some("mcp__cas__search")),
"mcp__cas__search permission missing"
);
}

#[test]
Expand Down Expand Up @@ -91,6 +116,18 @@ fn test_configure_merges_existing() {
allow_arr.iter().any(|v| v.as_str() == Some("Bash(cas :*)")),
"Bash(cas :*) permission should be added"
);
assert!(
allow_arr
.iter()
.any(|v| v.as_str() == Some("mcp__cas__task")),
"mcp__cas__task permission should be added"
);
assert!(
allow_arr
.iter()
.any(|v| v.as_str() == Some("mcp__cas__coordination")),
"mcp__cas__coordination permission should be added"
);
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion cas-cli/src/orchestration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@

pub mod names;

pub use names::generate_unique;
pub use names::{generate_minion_supervisor, generate_minion_unique, generate_unique};
103 changes: 103 additions & 0 deletions cas-cli/src/orchestration/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,69 @@ use rand::Rng;
use rand::seq::IndexedRandom;
use std::collections::HashSet;

// ── Minions theme names ──────────────────────────────────────────────────────

const MINION_WORKERS: &[&str] = &[
"kevin", "stuart", "bob", "dave", "jerry", "tim", "mark", "phil", "carl",
"norbert", "jorge", "otto", "steve", "herb", "pete", "donnie", "mel",
"abel", "tony", "walter",
];

const MINION_SUPERVISORS: &[&str] = &["gru", "dru", "nefario"];

/// Generate a single minion worker name (e.g., "kevin", "stuart")
pub fn generate_minion() -> String {
let mut rng = rand::rng();
let name = MINION_WORKERS.choose(&mut rng).unwrap_or(&"bob");
(*name).to_string()
}

/// Generate a minion supervisor name
pub fn generate_minion_supervisor() -> String {
let mut rng = rand::rng();
let name = MINION_SUPERVISORS.choose(&mut rng).unwrap_or(&"gru");
(*name).to_string()
}

/// Generate N unique minion worker names.
///
/// If more names are requested than available, appends a numeric suffix.
pub fn generate_minion_unique(count: usize) -> Vec<String> {
let mut names = Vec::with_capacity(count);
let mut rng = rand::rng();

// Shuffle the pool and take as many as we can
let mut pool: Vec<&str> = MINION_WORKERS.to_vec();
// Fisher-Yates shuffle
for i in (1..pool.len()).rev() {
let j = rng.random_range(0..=i);
pool.swap(i, j);
}

for (i, name) in pool.iter().enumerate() {
if i >= count {
break;
}
names.push((*name).to_string());
}

// If we need more than the pool, add suffixed duplicates
let mut suffix = 2;
while names.len() < count {
for name in &pool {
if names.len() >= count {
break;
}
names.push(format!("{name}-{suffix}"));
}
suffix += 1;
}

names
}

// ── Default theme names ──────────────────────────────────────────────────────

const ADJECTIVES: &[&str] = &[
"agile", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp", "daring", "eager",
"fair", "fast", "fierce", "gentle", "golden", "happy", "jolly", "keen", "kind", "lively",
Expand Down Expand Up @@ -123,4 +186,44 @@ mod tests {
let names = generate_unique(100);
assert_eq!(names.len(), 100);
}

#[test]
fn test_generate_minion_returns_valid_name() {
let name = generate_minion();
assert!(
MINION_WORKERS.contains(&name.as_str()),
"Minion name should be valid: {name}"
);
}

#[test]
fn test_generate_minion_supervisor_returns_valid_name() {
let name = generate_minion_supervisor();
assert!(
MINION_SUPERVISORS.contains(&name.as_str()),
"Supervisor name should be valid: {name}"
);
}

#[test]
fn test_generate_minion_unique_returns_correct_count() {
let names = generate_minion_unique(5);
assert_eq!(names.len(), 5);
}

#[test]
fn test_generate_minion_unique_all_different() {
let names = generate_minion_unique(10);
let unique: HashSet<_> = names.iter().collect();
assert_eq!(unique.len(), names.len(), "All minion names should be unique");
}

#[test]
fn test_generate_minion_unique_exceeds_pool() {
// More than 20 minion names, should use suffixes
let names = generate_minion_unique(25);
assert_eq!(names.len(), 25);
let unique: HashSet<_> = names.iter().collect();
assert_eq!(unique.len(), 25, "All names should still be unique");
}
}
25 changes: 18 additions & 7 deletions cas-cli/src/ui/factory/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,25 @@ impl FactoryApp {

let cas_dir = find_cas_root()?;

let all_names = generate_unique(config.workers + 1);
let supervisor_name = config
.supervisor_name
.unwrap_or_else(|| all_names[0].clone());
let worker_names: Vec<String> = if config.worker_names.is_empty() {
all_names[1..].to_vec()
let (supervisor_name, worker_names) = if config.minions_theme
&& config.supervisor_name.is_none()
&& config.worker_names.is_empty()
{
use crate::orchestration::names::{generate_minion_supervisor, generate_minion_unique};
let sup = generate_minion_supervisor();
let workers = generate_minion_unique(config.workers);
(sup, workers)
} else {
config.worker_names
let all_names = generate_unique(config.workers + 1);
let sup = config
.supervisor_name
.unwrap_or_else(|| all_names[0].clone());
let workers = if config.worker_names.is_empty() {
all_names[1..].to_vec()
} else {
config.worker_names
};
(sup, workers)
};

let (cols, rows) = crossterm::terminal::size().unwrap_or((120, 40));
Expand Down
Loading
Loading