The hook installer only ever wrote ~/.claude/settings.json, so a codex session reported no activity and never posted its first prompt — leaving it on the daemon's generated handle with no haiku name. Adds a Target enum (claude-code, codex) over the hook file's location and event set. One script and one marker serve both; codex swaps Notification for PermissionRequest and has no StopFailure, so it never reports Error. Both agents spell the prompt event UserPromptSubmit and put the text in the payload's `prompt`, so autoname needs no change. Codex will not run a hook it has not been told to trust; `maestro hooks install` says so, and the approval stays the operator's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
5.9 KiB
Rust
158 lines
5.9 KiB
Rust
//! Public dispatch for the `maestro` CLI subcommands.
|
|
//!
|
|
//! Every command talks to the local daemon (`[cli].daemon_url`, default
|
|
//! `http://127.0.0.1:7070`): `list` reads its session catalog, `spawn` / `kill`
|
|
//! hit its session endpoints, and `attach` opens `WS /api/attach/:id`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use maestro_protocol::DaemonClient;
|
|
|
|
use crate::attach::{parse_detach_key, run_attach_url};
|
|
use crate::cli::Command;
|
|
use crate::sessions::{run_kill, run_list, run_send, run_spawn};
|
|
use maestro_config::{default_config_path, MaestroConfigFile};
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[allow(clippy::result_large_err)]
|
|
pub enum CliError {
|
|
#[error("config: {0}")]
|
|
Config(#[from] maestro_config::ConfigError),
|
|
#[error("{0}")]
|
|
Client(#[from] maestro_protocol::ClientError),
|
|
#[error("{0}")]
|
|
Attach(#[from] crate::attach::AttachError),
|
|
#[error("{0}")]
|
|
Hooks(#[from] maestro_hooks::HooksError),
|
|
#[error("{0}")]
|
|
Resolve(#[from] crate::sessions::ResolveError),
|
|
#[error("nothing to send: pass the text as arguments or pipe it on stdin")]
|
|
NothingToSend,
|
|
#[error("could not determine the home directory ($HOME unset?)")]
|
|
NoHome,
|
|
#[error("{0}")]
|
|
Io(#[from] std::io::Error),
|
|
}
|
|
|
|
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
|
|
/// global flags (`--config`, `--daemon-url`) in alongside the variant.
|
|
#[allow(clippy::result_large_err)]
|
|
pub async fn dispatch(
|
|
command: Command,
|
|
config: Option<PathBuf>,
|
|
daemon_url_override: Option<String>,
|
|
) -> Result<(), CliError> {
|
|
let config_path = match config {
|
|
Some(p) => p,
|
|
None => default_config_path()?,
|
|
};
|
|
|
|
// `hooks install` is purely local: it must work with the daemon down, so
|
|
// it never touches the client.
|
|
if let Command::Hooks(args) = &command {
|
|
match args.command {
|
|
crate::cli::HooksCommand::Install => run_hooks_install()?,
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let daemon = client(&config_path, daemon_url_override)?;
|
|
|
|
match command {
|
|
Command::List(args) => {
|
|
let mut stdout = std::io::stdout().lock();
|
|
run_list(&daemon, &args, &mut stdout).await?;
|
|
}
|
|
Command::Spawn(args) => {
|
|
let attach_after = args.attach;
|
|
let session = run_spawn(&daemon, args).await?;
|
|
println!("spawned {}", session.id);
|
|
if attach_after {
|
|
// `spawn --attach` has no detach-key flag; use the attach
|
|
// default (Ctrl-q).
|
|
run_attach_url(&daemon.attach_url(session.id)?, CTRL_Q).await?;
|
|
}
|
|
}
|
|
Command::Attach(args) => {
|
|
let detach = parse_detach_key(&args.detach_key)?;
|
|
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
|
|
}
|
|
Command::Kill(args) => {
|
|
// #83: warn BEFORE the attempt, so the refusal is not a surprise.
|
|
// Best-effort: an unreachable list must not block the kill.
|
|
if !args.force {
|
|
warn_if_blocked(&daemon, args.session_id).await;
|
|
}
|
|
run_kill(&daemon, &args).await?;
|
|
println!("killed {}", args.session_id);
|
|
}
|
|
Command::Send(args) => {
|
|
let id = run_send(&daemon, &args, &mut std::io::stdin()).await?;
|
|
println!("sent to {id}");
|
|
}
|
|
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `maestro hooks install` (#106): write the shipped hook script under the
|
|
/// user's data dir and register it in every agent's hook file. Idempotent and
|
|
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
|
|
/// agent failing is reported and does not stop the others.
|
|
#[allow(clippy::result_large_err)]
|
|
fn run_hooks_install() -> Result<(), CliError> {
|
|
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
|
let script = maestro_hooks::script_path(&home);
|
|
for (target, result) in maestro_hooks::install_for_home(&home) {
|
|
let agent = target.label();
|
|
let settings = target.settings_path(&home);
|
|
match result {
|
|
Ok(true) => {
|
|
println!(
|
|
"installed {agent} activity hook: {} → {}",
|
|
script.display(),
|
|
settings.display()
|
|
);
|
|
if target == maestro_hooks::Target::Codex {
|
|
// Codex will not run a hook it has not been told to
|
|
// trust, and that approval is the operator's to give.
|
|
println!(" codex will ask you to trust this hook the next time it starts");
|
|
}
|
|
}
|
|
Ok(false) => println!("{agent} activity hook already up to date"),
|
|
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
|
const CTRL_Q: u8 = 0x11;
|
|
|
|
/// #83: say "this will be refused" before the operator commits to it, in the
|
|
/// same words [`maestro_protocol::InTheWay`] uses everywhere else.
|
|
async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
|
|
let Ok(sessions) = daemon.list_sessions().await else {
|
|
return;
|
|
};
|
|
if let Some(b) = sessions
|
|
.iter()
|
|
.find(|s| s.id == id)
|
|
.and_then(|s| s.blocked_work)
|
|
{
|
|
if !b.is_clear() {
|
|
eprintln!(
|
|
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to end it anyway"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// HTTP client pointed at the daemon's API. That surface needs no auth —
|
|
/// reaching `bind_addr` is the authorization.
|
|
#[allow(clippy::result_large_err)]
|
|
fn client(config_path: &Path, override_url: Option<String>) -> Result<DaemonClient, CliError> {
|
|
let cfg = MaestroConfigFile::load(config_path)?;
|
|
Ok(DaemonClient::new(cfg.resolve_daemon_url(override_url)))
|
|
}
|