use std::collections::BTreeMap;
use crate::paths::{routine_compiled_prompt_path, routine_scheduled_log_path};
use crate::routine_storage::read_local_env;
use super::agents::AgentCommand;
use super::model::Routine;
pub(crate) fn slugify(title: &str) -> String {
let segments: Vec<String> = title
.split('/')
.filter_map(|segment| {
let mut out = String::new();
let mut prev_dash = false;
for ch in segment.chars() {
if ch.is_alphanumeric() {
out.extend(ch.to_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let trimmed = out.trim_matches('-').to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
.collect();
if segments.is_empty() {
"routine".to_string()
} else {
segments.join("/")
}
}
pub(crate) const TMUX_SESSION_PREFIX: &str = "moadim-";
pub(crate) fn tmux_session_prefix(slug: &str) -> String {
format!("{TMUX_SESSION_PREFIX}{slug}-")
}
#[path = "command_prompt.rs"]
mod command_prompt;
pub(crate) use command_prompt::*;
#[path = "command_path_resolution.rs"]
mod command_path_resolution;
pub(crate) use command_path_resolution::*;
pub(crate) fn shell_quote(text: &str) -> String {
let mut out = String::from("'");
for ch in text.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
#[path = "command_system_prompt.rs"]
mod command_system_prompt;
pub(crate) use command_system_prompt::system_prompt_stmts;
pub(crate) fn is_valid_env_key(key: &str) -> bool {
let mut chars = key.chars();
matches!(chars.next(), Some(first) if first.is_ascii_alphabetic() || first == '_')
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
fn env_export_stmts(routine: &Routine) -> Vec<String> {
let slug = slugify(&routine.title);
let local_env = read_local_env(&slug);
let mut merged: BTreeMap<String, String> = BTreeMap::new();
for (key, value) in routine.env.iter().chain(local_env.iter()) {
if is_valid_env_key(key) && !value.contains('\n') && !value.contains('\r') {
merged.insert(key.clone(), value.clone());
} else {
log::warn!(
"routine {:?}: skipping invalid env var {key:?} (from routine.toml or \
routine.local.toml — invalid key or a newline in the value)",
routine.id
);
}
}
merged
.into_iter()
.map(|(key, value)| format!("export {key}={}", shell_quote(&value)))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TriggerSource {
Scheduled,
Manual,
}
pub(crate) fn build_routine_command(
routine: &Routine,
agent: &AgentCommand,
source: TriggerSource,
) -> String {
let slug = slugify(&routine.title);
let prompt_path = routine_compiled_prompt_path(&slug)
.to_string_lossy()
.into_owned();
let scheduled_log_path = routine_scheduled_log_path(&slug)
.to_string_lossy()
.into_owned();
let workbenches_base = crate::paths::workbenches_dir()
.to_string_lossy()
.into_owned();
let prompt_file_ref = "prompt.md";
let workbench_ref = ".";
let mut invocation = vec![agent.command.clone()];
for arg in &agent.args {
invocation.push(substitute(arg, workbench_ref, prompt_file_ref));
}
if let Some(model) = &routine.model {
invocation.push("--model".to_string());
invocation.push(shell_quote(model));
}
let invocation = invocation.join(" ");
let mut stmts = vec![
"umask 077".to_string(),
format!(
"export PATH=$PATH:{}",
shell_quote(&cron_path(&agent.command))
),
];
stmts.extend(env_export_stmts(routine));
stmts.push(r#"TS="$(date +%s)""#.to_string());
if source == TriggerSource::Scheduled {
stmts.push(format!(
r#"printf '%s\n' "$TS" >> {} || true"#,
shell_quote(&scheduled_log_path)
));
}
stmts.extend([
format!("SLUG={}", shell_quote(&slug)),
r#"RID="${TS}_$$""#.to_string(),
format!(r#"WB={}/"$SLUG-$RID""#, shell_quote(&workbenches_base)),
format!(r#"SESS="{TMUX_SESSION_PREFIX}$SLUG-$RID""#),
r#"mkdir -p "$WB""#.to_string(),
]);
let mut inner_stmts = Vec::new();
inner_stmts.extend(system_prompt_stmts(
&crate::paths::user_prompt_path().to_string_lossy(),
&routine.title,
&agent.instructions_file,
));
inner_stmts.extend([
format!(
r#"cp {src} "$WB/prompt.md" || {{ echo "moadim: missing routine prompt {src}; aborting launch" | tee -a "$WB/agent.log" >&2; exit 1; }}"#,
src = shell_quote(&prompt_path)
),
]);
if let Some(setup) = &agent.setup {
inner_stmts.push(format!(
r#"{{ {setup}; }} || {{ echo "moadim: agent setup failed; aborting launch" | tee -a "$WB/agent.log" >&2; exit 1; }}"#
));
}
let invocation_with_exit_code = format!(r#"{invocation}; printf '%s' "$?" > exit_code"#);
inner_stmts.push(format!(
r#"tmux new-session -d -s "$SESS" -c "$WB" {} \; pipe-pane -o -t "$SESS" "cat >> \"$WB\"/agent.log" || {{ echo "moadim: failed to start tmux session $SESS (already exists?); aborting launch" | tee -a "$WB/agent.log" >&2; exit 1; }}"#,
shell_quote(&invocation_with_exit_code)
));
stmts.push(format!(
r#"{{ {} ; }} >> "$WB/launch.log" 2>&1"#,
inner_stmts.join("; ")
));
stmts.join("; ")
}
#[cfg(test)]
#[path = "command_tests.rs"]
mod command_tests;
#[cfg(test)]
#[path = "command_bin_resolution_tests.rs"]
mod command_bin_resolution_tests;
#[cfg(test)]
#[path = "command_placeholder_tests.rs"]
mod command_placeholder_tests;