mirror of
https://github.com/openai/codex.git
synced 2026-05-20 11:12:43 +00:00
## Summary - Rewrite migrated external-agent hook commands by replacing the full hook script path token instead of only the `.claude/hooks/` segment. - Preserve quoting around the full rewritten target path so script names with spaces, absolute paths, and shell operators/redirection continue to work. - Apply `.claude/settings.local.json` over `.claude/settings.json` for config, MCP, and plugin migration so local scope matches Claude settings precedence. - Skip legacy command markdown without `description` frontmatter, including README-style docs under `.claude/commands`. ## Root Cause The previous hook rewrite handled `.claude/hooks/` as a substring replacement. For absolute source commands, that left the original project-root prefix before the newly quoted `.codex/hooks` directory, producing invalid commands like `project/'project/.codex/hooks'/script.sh`. The migration also only used project `settings.json` for config/MCP/plugin decisions, so local settings such as `disabledMcpjsonServers` could be ignored even though Claude gives local settings higher precedence than project settings. ## Validation - `just fmt` - `cargo test -p codex-external-agent-migration` - `cargo test -p codex-app-server external_agent_config` - `just fix -p codex-external-agent-migration` - `just fix -p codex-app-server` - `git diff --check`
2142 lines
69 KiB
Rust
2142 lines
69 KiB
Rust
//! Migration helpers for importing external-agent configuration into Codex.
|
|
|
|
use codex_hooks::HOOK_EVENT_NAMES;
|
|
use codex_hooks::HOOK_EVENT_NAMES_WITH_MATCHERS;
|
|
use serde_json::Value as JsonValue;
|
|
use serde_yaml::Value as YamlValue;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::BTreeSet;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use toml::Value as TomlValue;
|
|
|
|
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
|
|
const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
|
const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks";
|
|
const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks";
|
|
const COMMAND_SKILL_PREFIX: &str = "source-command";
|
|
const MAX_SKILL_NAME_LEN: usize = 64;
|
|
const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;
|
|
|
|
#[derive(Debug)]
|
|
struct ParsedDocument {
|
|
frontmatter: BTreeMap<String, FrontmatterValue>,
|
|
body: String,
|
|
frontmatter_error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum FrontmatterValue {
|
|
Scalar(String),
|
|
Other,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct AgentMetadata {
|
|
name: String,
|
|
description: String,
|
|
permission_mode: Option<String>,
|
|
effort: Option<String>,
|
|
}
|
|
|
|
pub fn build_mcp_config_from_external(
|
|
source_root: &Path,
|
|
external_agent_home: Option<&Path>,
|
|
settings: Option<&JsonValue>,
|
|
) -> io::Result<TomlValue> {
|
|
let mcp_servers = read_external_mcp_servers(source_root, external_agent_home)?;
|
|
if mcp_servers.is_empty() {
|
|
return Ok(TomlValue::Table(Default::default()));
|
|
}
|
|
|
|
let enabled_servers = settings
|
|
.and_then(|settings| settings.get("enabledMcpjsonServers"))
|
|
.map(json_string_vec)
|
|
.unwrap_or_default();
|
|
let disabled_servers = settings
|
|
.and_then(|settings| settings.get("disabledMcpjsonServers"))
|
|
.map(json_string_vec)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.collect::<BTreeSet<_>>();
|
|
|
|
let mut servers = toml::map::Map::new();
|
|
for (server_name, server_config) in mcp_servers {
|
|
if let Some(server) = mcp_server_toml_table(
|
|
&server_name,
|
|
server_config.as_object(),
|
|
&enabled_servers,
|
|
&disabled_servers,
|
|
) {
|
|
servers.insert(server_name.clone(), TomlValue::Table(server));
|
|
}
|
|
}
|
|
|
|
if servers.is_empty() {
|
|
return Ok(TomlValue::Table(Default::default()));
|
|
}
|
|
|
|
let mut root = toml::map::Map::new();
|
|
root.insert("mcp_servers".to_string(), TomlValue::Table(servers));
|
|
Ok(TomlValue::Table(root))
|
|
}
|
|
|
|
pub fn hooks_migration_description(
|
|
source_external_agent_dir: &Path,
|
|
target_hooks: &Path,
|
|
) -> io::Result<Option<String>> {
|
|
if hook_migration_event_names(source_external_agent_dir, target_hooks)?.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(format!(
|
|
"Migrate hooks from {} to {}",
|
|
source_external_agent_dir.display(),
|
|
target_hooks.display()
|
|
)))
|
|
}
|
|
|
|
pub fn hook_migration_event_names(
|
|
source_external_agent_dir: &Path,
|
|
target_hooks: &Path,
|
|
) -> io::Result<Vec<String>> {
|
|
let migration = hook_migration(source_external_agent_dir, target_hooks.parent())?;
|
|
Ok(migration.keys().cloned().collect())
|
|
}
|
|
|
|
pub fn import_hooks(source_external_agent_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
|
|
let Some(parent) = target_hooks.parent() else {
|
|
return Err(invalid_data_error("hooks target path has no parent"));
|
|
};
|
|
let migration = hook_migration(source_external_agent_dir, Some(parent))?;
|
|
if migration.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
|
|
fs::create_dir_all(parent)?;
|
|
|
|
let mut wrote_active_hooks = false;
|
|
if is_missing_or_empty_text_file(target_hooks)? {
|
|
copy_hook_scripts(source_external_agent_dir, parent)?;
|
|
let mut payload = serde_json::Map::new();
|
|
payload.insert("hooks".to_string(), JsonValue::Object(migration));
|
|
let rendered = serde_json::to_string_pretty(&JsonValue::Object(payload))
|
|
.map_err(|err| invalid_data_error(format!("failed to serialize hooks.json: {err}")))?;
|
|
fs::write(target_hooks, format!("{rendered}\n"))?;
|
|
wrote_active_hooks = true;
|
|
}
|
|
|
|
Ok(wrote_active_hooks)
|
|
}
|
|
|
|
pub fn count_missing_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<usize> {
|
|
Ok(missing_subagent_names(source_agents, target_agents)?.len())
|
|
}
|
|
|
|
pub fn missing_subagent_names(
|
|
source_agents: &Path,
|
|
target_agents: &Path,
|
|
) -> io::Result<Vec<String>> {
|
|
let mut names = Vec::new();
|
|
for source_file in agent_source_files(source_agents)? {
|
|
let document = parse_document(&source_file)?;
|
|
let Some(metadata) = agent_metadata(&document) else {
|
|
continue;
|
|
};
|
|
let Some(target) = subagent_target_file(&source_file, target_agents) else {
|
|
continue;
|
|
};
|
|
if !target.exists() {
|
|
names.push(metadata.name);
|
|
}
|
|
}
|
|
Ok(names)
|
|
}
|
|
|
|
pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<usize> {
|
|
if !source_agents.is_dir() {
|
|
return Ok(0);
|
|
}
|
|
|
|
fs::create_dir_all(target_agents)?;
|
|
let mut imported = 0usize;
|
|
for source_file in agent_source_files(source_agents)? {
|
|
let Some(target) = subagent_target_file(&source_file, target_agents) else {
|
|
continue;
|
|
};
|
|
if target.exists() {
|
|
continue;
|
|
}
|
|
let document = parse_document(&source_file)?;
|
|
let Some(metadata) = agent_metadata(&document) else {
|
|
continue;
|
|
};
|
|
fs::write(&target, render_agent_toml(&document.body, &metadata)?)?;
|
|
imported += 1;
|
|
}
|
|
|
|
Ok(imported)
|
|
}
|
|
|
|
pub fn count_missing_commands(source_commands: &Path, target_skills: &Path) -> io::Result<usize> {
|
|
Ok(missing_command_names(source_commands, target_skills)?.len())
|
|
}
|
|
|
|
pub fn missing_command_names(
|
|
source_commands: &Path,
|
|
target_skills: &Path,
|
|
) -> io::Result<Vec<String>> {
|
|
Ok(unique_supported_command_sources(source_commands)?
|
|
.into_iter()
|
|
.filter(|(_source_file, name)| !target_skills.join(name).exists())
|
|
.map(|(_source_file, name)| name)
|
|
.collect())
|
|
}
|
|
|
|
pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result<usize> {
|
|
if !source_commands.is_dir() {
|
|
return Ok(0);
|
|
}
|
|
|
|
fs::create_dir_all(target_skills)?;
|
|
let mut imported = 0usize;
|
|
for (source_file, name) in unique_supported_command_sources(source_commands)? {
|
|
let document = parse_document(&source_file)?;
|
|
let target_dir = target_skills.join(&name);
|
|
if target_dir.exists() {
|
|
continue;
|
|
}
|
|
fs::create_dir_all(&target_dir)?;
|
|
let source_name = command_source_name(source_commands, &source_file);
|
|
let Some(description) = command_skill_description(&document, &source_name) else {
|
|
continue;
|
|
};
|
|
fs::write(
|
|
target_dir.join("SKILL.md"),
|
|
render_command_skill(&document.body, &name, &description, &source_name),
|
|
)?;
|
|
imported += 1;
|
|
}
|
|
|
|
Ok(imported)
|
|
}
|
|
|
|
fn read_external_mcp_servers(
|
|
source_root: &Path,
|
|
external_agent_home: Option<&Path>,
|
|
) -> io::Result<BTreeMap<String, JsonValue>> {
|
|
let mut servers = BTreeMap::new();
|
|
let project_config_file = external_agent_project_config_file();
|
|
for relative_path in [
|
|
EXTERNAL_AGENT_MCP_CONFIG_FILE.to_string(),
|
|
project_config_file.clone(),
|
|
] {
|
|
let source_file = source_root.join(&relative_path);
|
|
if !source_file.is_file() {
|
|
continue;
|
|
}
|
|
let raw = fs::read_to_string(&source_file)?;
|
|
let parsed: JsonValue = serde_json::from_str(&raw)
|
|
.map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?;
|
|
append_mcp_servers_from_value(&parsed, &mut servers, McpServerMerge::Overwrite);
|
|
if relative_path == project_config_file
|
|
&& let Some(projects) = parsed.get("projects").and_then(JsonValue::as_object)
|
|
{
|
|
for (project_path, project_config) in projects {
|
|
if project_path_matches_source_root(project_path, source_root) {
|
|
append_mcp_servers_from_value(
|
|
project_config,
|
|
&mut servers,
|
|
McpServerMerge::Overwrite,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(external_agent_root) = external_agent_home.and_then(Path::parent)
|
|
&& external_agent_root != source_root
|
|
{
|
|
append_external_agent_project_mcp_servers(
|
|
&external_agent_root.join(external_agent_project_config_file()),
|
|
source_root,
|
|
&mut servers,
|
|
)?;
|
|
}
|
|
|
|
Ok(servers)
|
|
}
|
|
|
|
fn append_external_agent_project_mcp_servers(
|
|
source_file: &Path,
|
|
source_root: &Path,
|
|
servers: &mut BTreeMap<String, JsonValue>,
|
|
) -> io::Result<()> {
|
|
if !source_file.is_file() {
|
|
return Ok(());
|
|
}
|
|
let raw = fs::read_to_string(source_file)?;
|
|
let parsed: JsonValue = serde_json::from_str(&raw)
|
|
.map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?;
|
|
let Some(projects) = parsed.get("projects").and_then(JsonValue::as_object) else {
|
|
return Ok(());
|
|
};
|
|
for (project_path, project_config) in projects {
|
|
if project_path_matches_source_root(project_path, source_root) {
|
|
append_mcp_servers_from_value(
|
|
project_config,
|
|
servers,
|
|
McpServerMerge::PreserveExisting,
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum McpServerMerge {
|
|
Overwrite,
|
|
PreserveExisting,
|
|
}
|
|
|
|
fn append_mcp_servers_from_value(
|
|
value: &JsonValue,
|
|
servers: &mut BTreeMap<String, JsonValue>,
|
|
merge: McpServerMerge,
|
|
) {
|
|
let Some(mcp_servers) = value.get("mcpServers").and_then(JsonValue::as_object) else {
|
|
return;
|
|
};
|
|
for (server_name, server_config) in mcp_servers {
|
|
match merge {
|
|
McpServerMerge::Overwrite => {
|
|
servers.insert(server_name.clone(), server_config.clone());
|
|
}
|
|
McpServerMerge::PreserveExisting => {
|
|
servers
|
|
.entry(server_name.clone())
|
|
.or_insert_with(|| server_config.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn project_path_matches_source_root(project_path: &str, source_root: &Path) -> bool {
|
|
let project_path = Path::new(project_path);
|
|
if project_path == source_root {
|
|
return true;
|
|
}
|
|
let Ok(project_path) = project_path.canonicalize() else {
|
|
return false;
|
|
};
|
|
source_root
|
|
.canonicalize()
|
|
.is_ok_and(|source_root| source_root == project_path)
|
|
}
|
|
|
|
fn mcp_server_toml_table(
|
|
server_name: &str,
|
|
server_config: Option<&serde_json::Map<String, JsonValue>>,
|
|
enabled_servers: &[String],
|
|
disabled_servers: &BTreeSet<String>,
|
|
) -> Option<toml::map::Map<String, TomlValue>> {
|
|
let mut table = toml::map::Map::new();
|
|
let server_config = server_config?;
|
|
let transport_type = server_config.get("type").and_then(JsonValue::as_str);
|
|
if mcp_server_is_disabled(
|
|
server_name,
|
|
server_config,
|
|
enabled_servers,
|
|
disabled_servers,
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
if let Some(command) = server_config.get("command").and_then(json_string) {
|
|
if !matches!(transport_type, None | Some("stdio")) {
|
|
return None;
|
|
}
|
|
if contains_env_placeholder(&command) {
|
|
return None;
|
|
}
|
|
table.insert("command".to_string(), TomlValue::String(command));
|
|
if let Some(args) = server_config.get("args") {
|
|
let args = json_string_vec(args);
|
|
if args.iter().any(|arg| contains_env_placeholder(arg)) {
|
|
return None;
|
|
}
|
|
let args = args.into_iter().map(TomlValue::String).collect::<Vec<_>>();
|
|
if !args.is_empty() {
|
|
table.insert("args".to_string(), TomlValue::Array(args));
|
|
}
|
|
}
|
|
if let Some(env) = server_config.get("env").and_then(JsonValue::as_object) {
|
|
append_env_config(&mut table, env)?;
|
|
}
|
|
} else if let Some(url) = server_config.get("url").and_then(json_string) {
|
|
if !matches!(
|
|
transport_type,
|
|
None | Some("http") | Some("streamable_http")
|
|
) {
|
|
return None;
|
|
}
|
|
if contains_env_placeholder(&url) {
|
|
return None;
|
|
}
|
|
table.insert("url".to_string(), TomlValue::String(url));
|
|
if let Some(headers) = server_config.get("headers").and_then(JsonValue::as_object) {
|
|
append_header_config(&mut table, headers)?;
|
|
}
|
|
} else {
|
|
return None;
|
|
}
|
|
|
|
Some(table)
|
|
}
|
|
|
|
fn mcp_server_is_disabled(
|
|
server_name: &str,
|
|
server_config: &serde_json::Map<String, JsonValue>,
|
|
enabled_servers: &[String],
|
|
disabled_servers: &BTreeSet<String>,
|
|
) -> bool {
|
|
server_config
|
|
.get("enabled")
|
|
.and_then(JsonValue::as_bool)
|
|
.is_some_and(|enabled| !enabled)
|
|
|| server_config
|
|
.get("disabled")
|
|
.and_then(JsonValue::as_bool)
|
|
.unwrap_or(false)
|
|
|| (!enabled_servers.is_empty() && !enabled_servers.iter().any(|name| name == server_name))
|
|
|| disabled_servers.contains(server_name)
|
|
}
|
|
|
|
fn append_header_config(
|
|
table: &mut toml::map::Map<String, TomlValue>,
|
|
headers: &serde_json::Map<String, JsonValue>,
|
|
) -> Option<()> {
|
|
let mut static_headers = toml::map::Map::new();
|
|
let mut env_headers = toml::map::Map::new();
|
|
|
|
for (key, value) in headers {
|
|
let header_value = json_string(value).unwrap_or_else(|| value.to_string());
|
|
if key.eq_ignore_ascii_case("authorization")
|
|
&& let Some(token_env) = header_value
|
|
.strip_prefix("Bearer ")
|
|
.and_then(parse_env_placeholder)
|
|
{
|
|
table.insert(
|
|
"bearer_token_env_var".to_string(),
|
|
TomlValue::String(token_env),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if let Some(env_var) = parse_env_placeholder(&header_value) {
|
|
env_headers.insert(key.clone(), TomlValue::String(env_var));
|
|
} else if contains_env_placeholder(&header_value) {
|
|
return None;
|
|
} else {
|
|
static_headers.insert(key.clone(), TomlValue::String(header_value));
|
|
}
|
|
}
|
|
|
|
if !static_headers.is_empty() {
|
|
table.insert("http_headers".to_string(), TomlValue::Table(static_headers));
|
|
}
|
|
if !env_headers.is_empty() {
|
|
table.insert(
|
|
"env_http_headers".to_string(),
|
|
TomlValue::Table(env_headers),
|
|
);
|
|
}
|
|
Some(())
|
|
}
|
|
|
|
fn append_env_config(
|
|
table: &mut toml::map::Map<String, TomlValue>,
|
|
env: &serde_json::Map<String, JsonValue>,
|
|
) -> Option<()> {
|
|
let mut static_env = toml::map::Map::new();
|
|
let mut env_vars = Vec::new();
|
|
|
|
for (key, value) in env {
|
|
let env_value = json_string(value).unwrap_or_else(|| value.to_string());
|
|
if parse_env_placeholder(&env_value).as_deref() == Some(key.as_str()) {
|
|
env_vars.push(TomlValue::String(key.clone()));
|
|
} else if contains_env_placeholder(&env_value) {
|
|
return None;
|
|
} else {
|
|
static_env.insert(key.clone(), TomlValue::String(env_value));
|
|
}
|
|
}
|
|
|
|
if !env_vars.is_empty() {
|
|
table.insert("env_vars".to_string(), TomlValue::Array(env_vars));
|
|
}
|
|
if !static_env.is_empty() {
|
|
table.insert("env".to_string(), TomlValue::Table(static_env));
|
|
}
|
|
Some(())
|
|
}
|
|
|
|
fn parse_env_placeholder(value: &str) -> Option<String> {
|
|
let inner = value.strip_prefix("${")?.strip_suffix('}')?;
|
|
let name = inner
|
|
.split_once(":-")
|
|
.map_or(inner, |(name, _default)| name);
|
|
let mut chars = name.chars();
|
|
let first = chars.next()?;
|
|
if !(first == '_' || first.is_ascii_alphabetic()) {
|
|
return None;
|
|
}
|
|
if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
|
|
return None;
|
|
}
|
|
Some(name.to_string())
|
|
}
|
|
|
|
fn contains_env_placeholder(value: &str) -> bool {
|
|
value.contains("${")
|
|
}
|
|
|
|
fn hook_migration(
|
|
source_external_agent_dir: &Path,
|
|
target_config_dir: Option<&Path>,
|
|
) -> io::Result<serde_json::Map<String, JsonValue>> {
|
|
let mut settings_files = Vec::new();
|
|
let mut disable_all_hooks = None;
|
|
for settings_name in ["settings.json", "settings.local.json"] {
|
|
let settings_file = source_external_agent_dir.join(settings_name);
|
|
if !settings_file.is_file() {
|
|
continue;
|
|
}
|
|
let raw = fs::read_to_string(&settings_file)?;
|
|
let settings: JsonValue = serde_json::from_str(&raw)
|
|
.map_err(|err| invalid_data_error(format!("invalid hooks settings: {err}")))?;
|
|
if let Some(disabled) = settings.get("disableAllHooks").and_then(JsonValue::as_bool) {
|
|
disable_all_hooks = Some(disabled);
|
|
}
|
|
settings_files.push(settings);
|
|
}
|
|
|
|
if disable_all_hooks.unwrap_or(false) {
|
|
return Ok(serde_json::Map::new());
|
|
}
|
|
|
|
let mut migration = serde_json::Map::new();
|
|
for settings in settings_files {
|
|
append_convertible_hook_groups(&settings, &mut migration, target_config_dir);
|
|
}
|
|
|
|
Ok(migration)
|
|
}
|
|
|
|
fn append_convertible_hook_groups(
|
|
settings: &JsonValue,
|
|
hooks_payload: &mut serde_json::Map<String, JsonValue>,
|
|
target_config_dir: Option<&Path>,
|
|
) {
|
|
let Some(hooks_config) = settings.get("hooks").and_then(JsonValue::as_object) else {
|
|
return;
|
|
};
|
|
|
|
for event_name in HOOK_EVENT_NAMES {
|
|
let Some(groups) = hooks_config.get(event_name).and_then(JsonValue::as_array) else {
|
|
continue;
|
|
};
|
|
for group in groups {
|
|
let Some(group_object) = group.as_object() else {
|
|
continue;
|
|
};
|
|
if group_object.contains_key("if")
|
|
|| group_object
|
|
.keys()
|
|
.any(|key| !matches!(key.as_str(), "matcher" | "hooks"))
|
|
{
|
|
continue;
|
|
}
|
|
let mut hook_commands = Vec::new();
|
|
if let Some(hooks) = group_object.get("hooks").and_then(JsonValue::as_array) {
|
|
for hook in hooks {
|
|
let Some(hook_object) = hook.as_object() else {
|
|
continue;
|
|
};
|
|
let hook_type = hook_object
|
|
.get("type")
|
|
.and_then(JsonValue::as_str)
|
|
.unwrap_or("command");
|
|
if hook_type != "command" {
|
|
continue;
|
|
}
|
|
if hook_object.keys().any(|key| {
|
|
!matches!(
|
|
key.as_str(),
|
|
"type"
|
|
| "command"
|
|
| "timeout"
|
|
| "timeoutSec"
|
|
| "statusMessage"
|
|
| "async"
|
|
)
|
|
}) {
|
|
continue;
|
|
}
|
|
if hook_object
|
|
.get("async")
|
|
.and_then(JsonValue::as_bool)
|
|
.unwrap_or(false)
|
|
{
|
|
continue;
|
|
}
|
|
if ["asyncRewake", "shell", "once"]
|
|
.into_iter()
|
|
.any(|field| hook_object.contains_key(field))
|
|
{
|
|
continue;
|
|
}
|
|
let Some(command) = hook_object
|
|
.get("command")
|
|
.and_then(JsonValue::as_str)
|
|
.map(str::trim)
|
|
.filter(|command| !command.is_empty())
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let mut command_payload = serde_json::Map::new();
|
|
command_payload
|
|
.insert("type".to_string(), JsonValue::String("command".to_string()));
|
|
command_payload.insert(
|
|
"command".to_string(),
|
|
JsonValue::String(rewrite_hook_command(command, target_config_dir)),
|
|
);
|
|
if let Some(timeout) = hook_object
|
|
.get("timeout")
|
|
.or_else(|| hook_object.get("timeoutSec"))
|
|
.and_then(json_u64)
|
|
{
|
|
command_payload.insert(
|
|
"timeout".to_string(),
|
|
JsonValue::Number(serde_json::Number::from(timeout)),
|
|
);
|
|
}
|
|
if let Some(status_message) =
|
|
hook_object.get("statusMessage").and_then(JsonValue::as_str)
|
|
{
|
|
command_payload.insert(
|
|
"statusMessage".to_string(),
|
|
JsonValue::String(rewrite_external_agent_terms(status_message)),
|
|
);
|
|
}
|
|
hook_commands.push(JsonValue::Object(command_payload));
|
|
}
|
|
}
|
|
if hook_commands.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let mut group_payload = serde_json::Map::new();
|
|
if HOOK_EVENT_NAMES_WITH_MATCHERS.contains(&event_name)
|
|
&& let Some(matcher) = group_object.get("matcher").and_then(JsonValue::as_str)
|
|
{
|
|
group_payload.insert(
|
|
"matcher".to_string(),
|
|
JsonValue::String(matcher.to_string()),
|
|
);
|
|
}
|
|
group_payload.insert("hooks".to_string(), JsonValue::Array(hook_commands));
|
|
if let Some(groups) = hooks_payload
|
|
.entry(event_name.to_string())
|
|
.or_insert_with(|| JsonValue::Array(Vec::new()))
|
|
.as_array_mut()
|
|
{
|
|
groups.push(JsonValue::Object(group_payload));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rewrite_hook_command(command: &str, target_config_dir: Option<&Path>) -> String {
|
|
let Some(target_config_dir) = target_config_dir else {
|
|
return command.to_string();
|
|
};
|
|
if looks_like_windows_hook_command(command) {
|
|
return command.to_string();
|
|
}
|
|
let target_hooks_dir = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
|
|
let source_hooks_path = format!(
|
|
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/",
|
|
external_agent_config_dir()
|
|
);
|
|
let command = replace_quoted_hook_paths(command, '\'', &source_hooks_path, &target_hooks_dir);
|
|
let command = replace_quoted_hook_paths(&command, '"', &source_hooks_path, &target_hooks_dir);
|
|
replace_unquoted_hook_paths(&command, &source_hooks_path, &target_hooks_dir)
|
|
}
|
|
|
|
fn replace_quoted_hook_paths(
|
|
command: &str,
|
|
quote: char,
|
|
source_hooks_path: &str,
|
|
target_hooks_dir: &Path,
|
|
) -> String {
|
|
let mut rewritten = command.to_string();
|
|
let mut search_start = 0usize;
|
|
while let Some(relative_start) = rewritten[search_start..].find(quote) {
|
|
let start = search_start + relative_start;
|
|
let content_start = start + quote.len_utf8();
|
|
let Some(relative_end) = rewritten[content_start..].find(quote) else {
|
|
break;
|
|
};
|
|
let end = content_start + relative_end;
|
|
let content = &rewritten[content_start..end];
|
|
if let Some(source_hooks_start) = content.find(source_hooks_path) {
|
|
let suffix_start = source_hooks_start + source_hooks_path.len();
|
|
let suffix = &content[suffix_start..];
|
|
let Some(replacement) =
|
|
target_hook_path_replacement(target_hooks_dir, content, source_hooks_start, suffix)
|
|
else {
|
|
search_start = end + quote.len_utf8();
|
|
continue;
|
|
};
|
|
rewritten.replace_range(start..end + quote.len_utf8(), &replacement);
|
|
search_start = start + replacement.len();
|
|
} else {
|
|
search_start = end + quote.len_utf8();
|
|
}
|
|
}
|
|
rewritten
|
|
}
|
|
|
|
fn replace_unquoted_hook_paths(
|
|
command: &str,
|
|
source_hooks_path: &str,
|
|
target_hooks_dir: &Path,
|
|
) -> String {
|
|
let mut rewritten = command.to_string();
|
|
let mut search_start = 0usize;
|
|
while let Some(source_hooks_start) =
|
|
find_unquoted_source_hook_path(&rewritten, source_hooks_path, search_start)
|
|
{
|
|
let path_start = shell_path_start(&rewritten, source_hooks_start);
|
|
let path_end = shell_path_end(&rewritten, source_hooks_start + source_hooks_path.len());
|
|
if is_assignment_value_start(&rewritten, path_start) {
|
|
search_start = source_hooks_start + source_hooks_path.len();
|
|
continue;
|
|
}
|
|
let path = rewritten[path_start..path_end].to_string();
|
|
let suffix = rewritten[source_hooks_start + source_hooks_path.len()..path_end].to_string();
|
|
if let Some(replacement) = target_hook_path_replacement(
|
|
target_hooks_dir,
|
|
&path,
|
|
source_hooks_start - path_start,
|
|
&suffix,
|
|
) {
|
|
rewritten.replace_range(path_start..path_end, &replacement);
|
|
search_start = path_start + replacement.len();
|
|
} else {
|
|
search_start = source_hooks_start + source_hooks_path.len();
|
|
}
|
|
}
|
|
rewritten
|
|
}
|
|
|
|
fn find_unquoted_source_hook_path(
|
|
command: &str,
|
|
source_hooks_path: &str,
|
|
start: usize,
|
|
) -> Option<usize> {
|
|
let mut in_single_quote = false;
|
|
let mut in_double_quote = false;
|
|
let mut escaped = false;
|
|
for (offset, ch) in command[start..].char_indices() {
|
|
let index = start + offset;
|
|
if escaped {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if !in_single_quote && ch == '\\' {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
match ch {
|
|
'\'' if !in_double_quote => {
|
|
in_single_quote = !in_single_quote;
|
|
}
|
|
'"' if !in_single_quote => {
|
|
in_double_quote = !in_double_quote;
|
|
}
|
|
_ if !in_single_quote
|
|
&& !in_double_quote
|
|
&& command[index..].starts_with(source_hooks_path) =>
|
|
{
|
|
return Some(index);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn is_pure_shell_path_content(content: &str, source_hooks_start: usize) -> bool {
|
|
let prefix = &content[..source_hooks_start];
|
|
(prefix.is_empty() || prefix == "./" || prefix.ends_with('/'))
|
|
&& !prefix.chars().any(is_shell_path_boundary)
|
|
}
|
|
|
|
fn shell_path_start(command: &str, end: usize) -> usize {
|
|
command[..end]
|
|
.char_indices()
|
|
.filter_map(|(index, ch)| is_shell_path_boundary(ch).then_some(index + ch.len_utf8()))
|
|
.next_back()
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn shell_path_end(command: &str, start: usize) -> usize {
|
|
let mut escaped = false;
|
|
for (offset, ch) in command[start..].char_indices() {
|
|
if escaped {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if ch == '\\' {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if is_shell_path_boundary(ch) {
|
|
return start + offset;
|
|
}
|
|
}
|
|
command.len()
|
|
}
|
|
|
|
fn is_shell_path_boundary(ch: char) -> bool {
|
|
ch.is_whitespace() || matches!(ch, '=' | ';' | '|' | '&' | '<' | '>' | '(' | ')')
|
|
}
|
|
|
|
fn is_assignment_value_start(command: &str, path_start: usize) -> bool {
|
|
command[..path_start]
|
|
.chars()
|
|
.next_back()
|
|
.is_some_and(|ch| ch == '=')
|
|
}
|
|
|
|
fn target_hook_path_replacement(
|
|
target_hooks_dir: &Path,
|
|
path: &str,
|
|
source_hooks_start: usize,
|
|
suffix: &str,
|
|
) -> Option<String> {
|
|
if !is_pure_shell_path_content(path, source_hooks_start) || !is_static_hook_path_suffix(suffix)
|
|
{
|
|
return None;
|
|
}
|
|
Some(shell_single_quote(
|
|
target_hooks_dir.join(suffix).to_string_lossy().as_ref(),
|
|
))
|
|
}
|
|
|
|
fn is_static_hook_path_suffix(suffix: &str) -> bool {
|
|
!suffix.is_empty()
|
|
&& !suffix
|
|
.chars()
|
|
.any(|ch| matches!(ch, '\\' | '$' | '`' | '*' | '?' | '[' | '{' | '}'))
|
|
}
|
|
|
|
fn looks_like_windows_hook_command(command: &str) -> bool {
|
|
let source_hooks_backslash_path = format!(
|
|
r"{}\{EXTERNAL_AGENT_HOOKS_SUBDIR}\",
|
|
external_agent_config_dir()
|
|
);
|
|
let project_dir_env_var = external_agent_project_dir_env_var();
|
|
command.contains(&source_hooks_backslash_path)
|
|
|| command.contains(&format!("%{project_dir_env_var}%"))
|
|
|| command.contains(&format!("$env:{project_dir_env_var}"))
|
|
}
|
|
|
|
fn shell_single_quote(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "'\\''"))
|
|
}
|
|
|
|
fn copy_hook_scripts(source_external_agent_dir: &Path, target_config_dir: &Path) -> io::Result<()> {
|
|
let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR);
|
|
if !source_hooks.is_dir() {
|
|
return Ok(());
|
|
}
|
|
let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
|
|
copy_dir_recursive_skip_existing(&source_hooks, &target_hooks)
|
|
}
|
|
|
|
fn copy_dir_recursive_skip_existing(source: &Path, target: &Path) -> io::Result<()> {
|
|
fs::create_dir_all(target)?;
|
|
for entry in fs::read_dir(source)? {
|
|
let entry = entry?;
|
|
let source_path = entry.path();
|
|
let target_path = target.join(entry.file_name());
|
|
let file_type = entry.file_type()?;
|
|
if file_type.is_dir() {
|
|
copy_dir_recursive_skip_existing(&source_path, &target_path)?;
|
|
} else if file_type.is_file() && !target_path.exists() {
|
|
fs::copy(source_path, target_path)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn agent_source_files(source_agents: &Path) -> io::Result<Vec<PathBuf>> {
|
|
if !source_agents.is_dir() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut files = Vec::new();
|
|
for entry in fs::read_dir(source_agents)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if !entry.file_type()?.is_file()
|
|
|| path.extension().and_then(|ext| ext.to_str()) != Some("md")
|
|
{
|
|
continue;
|
|
}
|
|
if path.file_stem().and_then(|stem| stem.to_str()) == Some("README") {
|
|
continue;
|
|
}
|
|
files.push(path);
|
|
}
|
|
files.sort();
|
|
Ok(files)
|
|
}
|
|
|
|
fn subagent_target_file(source_file: &Path, target_agents: &Path) -> Option<PathBuf> {
|
|
Some(target_agents.join(format!("{}.toml", source_file.file_stem()?.to_str()?)))
|
|
}
|
|
|
|
fn command_source_files(source_commands: &Path) -> io::Result<Vec<PathBuf>> {
|
|
let mut files = Vec::new();
|
|
collect_markdown_files(source_commands, &mut files)?;
|
|
files.sort();
|
|
Ok(files)
|
|
}
|
|
|
|
fn unique_supported_command_sources(source_commands: &Path) -> io::Result<Vec<(PathBuf, String)>> {
|
|
let mut by_name = BTreeMap::<String, Vec<PathBuf>>::new();
|
|
for source_file in command_source_files(source_commands)? {
|
|
let document = parse_document(&source_file)?;
|
|
let Some(name) = command_skill_name_if_supported(source_commands, &source_file, &document)
|
|
else {
|
|
continue;
|
|
};
|
|
by_name.entry(name).or_default().push(source_file);
|
|
}
|
|
|
|
Ok(by_name
|
|
.into_iter()
|
|
.filter_map(|(name, source_files)| {
|
|
let [source_file] = source_files.as_slice() else {
|
|
return None;
|
|
};
|
|
Some((source_file.clone(), name))
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn collect_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
|
|
if !dir.is_dir() {
|
|
return Ok(());
|
|
}
|
|
|
|
for entry in fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
let file_type = entry.file_type()?;
|
|
if file_type.is_dir() {
|
|
collect_markdown_files(&path, files)?;
|
|
} else if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md")
|
|
{
|
|
files.push(path);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_document(source_file: &Path) -> io::Result<ParsedDocument> {
|
|
let content = fs::read_to_string(source_file)?;
|
|
Ok(parse_document_content(&content))
|
|
}
|
|
|
|
fn parse_document_content(content: &str) -> ParsedDocument {
|
|
let Some(rest) = content
|
|
.strip_prefix("---\n")
|
|
.or_else(|| content.strip_prefix("---\r\n"))
|
|
else {
|
|
return ParsedDocument {
|
|
frontmatter: BTreeMap::new(),
|
|
body: content.to_string(),
|
|
frontmatter_error: None,
|
|
};
|
|
};
|
|
let Some((end, body_start)) = frontmatter_end(rest) else {
|
|
return ParsedDocument {
|
|
frontmatter: BTreeMap::new(),
|
|
body: content.to_string(),
|
|
frontmatter_error: None,
|
|
};
|
|
};
|
|
|
|
let raw_frontmatter = &rest[..end];
|
|
let body = &rest[body_start..];
|
|
let (frontmatter, frontmatter_error) = parse_frontmatter(raw_frontmatter);
|
|
ParsedDocument {
|
|
frontmatter,
|
|
body: body.to_string(),
|
|
frontmatter_error,
|
|
}
|
|
}
|
|
|
|
fn frontmatter_end(rest: &str) -> Option<(usize, usize)> {
|
|
[
|
|
"\r\n---\r\n",
|
|
"\r\n---\n",
|
|
"\n---\r\n",
|
|
"\n---\n",
|
|
"\r\n---",
|
|
"\n---",
|
|
]
|
|
.into_iter()
|
|
.filter_map(|delimiter| rest.find(delimiter).map(|end| (end, end + delimiter.len())))
|
|
.min_by_key(|(end, _body_start)| *end)
|
|
}
|
|
|
|
fn parse_frontmatter(
|
|
raw_frontmatter: &str,
|
|
) -> (BTreeMap<String, FrontmatterValue>, Option<String>) {
|
|
let parsed: YamlValue = match serde_yaml::from_str(raw_frontmatter) {
|
|
Ok(parsed) => parsed,
|
|
Err(err) => return (BTreeMap::new(), Some(err.to_string())),
|
|
};
|
|
let Some(mapping) = parsed.as_mapping() else {
|
|
return (
|
|
BTreeMap::new(),
|
|
Some("frontmatter is not a YAML mapping".to_string()),
|
|
);
|
|
};
|
|
|
|
let mut frontmatter = BTreeMap::new();
|
|
for (key, value) in mapping {
|
|
let Some(key) = key.as_str().map(str::trim).filter(|key| !key.is_empty()) else {
|
|
continue;
|
|
};
|
|
frontmatter.insert(key.to_string(), frontmatter_value_from_yaml(value));
|
|
}
|
|
|
|
(frontmatter, None)
|
|
}
|
|
|
|
fn frontmatter_value_from_yaml(value: &YamlValue) -> FrontmatterValue {
|
|
match value {
|
|
YamlValue::String(value) => FrontmatterValue::Scalar(value.trim().to_string()),
|
|
YamlValue::Bool(value) => FrontmatterValue::Scalar(value.to_string()),
|
|
YamlValue::Number(value) => FrontmatterValue::Scalar(value.to_string()),
|
|
YamlValue::Null | YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => {
|
|
FrontmatterValue::Other
|
|
}
|
|
}
|
|
}
|
|
|
|
fn agent_metadata(document: &ParsedDocument) -> Option<AgentMetadata> {
|
|
if document.frontmatter_error.is_some() || document.body.trim().is_empty() {
|
|
return None;
|
|
}
|
|
let name = document
|
|
.frontmatter
|
|
.get("name")
|
|
.and_then(FrontmatterValue::as_scalar)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(ToOwned::to_owned)?;
|
|
|
|
let description = document
|
|
.frontmatter
|
|
.get("description")
|
|
.and_then(FrontmatterValue::as_scalar)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(ToOwned::to_owned)?;
|
|
|
|
Some(AgentMetadata {
|
|
name,
|
|
description,
|
|
permission_mode: frontmatter_string(&document.frontmatter, "permissionMode"),
|
|
effort: frontmatter_string(&document.frontmatter, "effort"),
|
|
})
|
|
}
|
|
|
|
fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result<String> {
|
|
let mut document = toml::map::Map::new();
|
|
document.insert("name".to_string(), TomlValue::String(metadata.name.clone()));
|
|
document.insert(
|
|
"description".to_string(),
|
|
TomlValue::String(rewrite_external_agent_terms(&metadata.description)),
|
|
);
|
|
if let Some(effort) = metadata.effort.as_ref()
|
|
&& let Some(effort) = map_agent_reasoning_effort(effort)
|
|
{
|
|
document.insert(
|
|
"model_reasoning_effort".to_string(),
|
|
TomlValue::String(effort),
|
|
);
|
|
}
|
|
if let Some(sandbox_mode) = metadata
|
|
.permission_mode
|
|
.as_deref()
|
|
.and_then(map_agent_permission_mode)
|
|
{
|
|
document.insert(
|
|
"sandbox_mode".to_string(),
|
|
TomlValue::String(sandbox_mode.to_string()),
|
|
);
|
|
}
|
|
document.insert(
|
|
"developer_instructions".to_string(),
|
|
TomlValue::String(render_agent_body(body)),
|
|
);
|
|
|
|
let serialized = toml::to_string_pretty(&TomlValue::Table(document))
|
|
.map_err(|err| invalid_data_error(format!("failed to serialize agent TOML: {err}")))?;
|
|
Ok(format!("{}\n", serialized.trim_end()))
|
|
}
|
|
|
|
fn render_agent_body(body: &str) -> String {
|
|
let body = rewrite_external_agent_terms(body.trim());
|
|
if body.is_empty() {
|
|
"No subagent instructions were found.".to_string()
|
|
} else {
|
|
body
|
|
}
|
|
}
|
|
|
|
fn command_skill_name(source_commands: &Path, source_file: &Path) -> String {
|
|
slugify_name(&format!(
|
|
"{COMMAND_SKILL_PREFIX}-{}",
|
|
command_source_name(source_commands, source_file)
|
|
))
|
|
}
|
|
|
|
fn command_skill_name_if_supported(
|
|
source_commands: &Path,
|
|
source_file: &Path,
|
|
document: &ParsedDocument,
|
|
) -> Option<String> {
|
|
if source_file.file_stem().and_then(|stem| stem.to_str()) == Some("README") {
|
|
return None;
|
|
}
|
|
let source_name = command_source_name(source_commands, source_file);
|
|
let description = command_skill_description(document, &source_name)?;
|
|
let name = command_skill_name(source_commands, source_file);
|
|
if name.chars().count() > MAX_SKILL_NAME_LEN {
|
|
return None;
|
|
}
|
|
if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN {
|
|
return None;
|
|
}
|
|
if has_unsupported_command_template_features(&document.body) {
|
|
return None;
|
|
}
|
|
Some(name)
|
|
}
|
|
|
|
fn command_skill_description(document: &ParsedDocument, _source_name: &str) -> Option<String> {
|
|
document
|
|
.frontmatter
|
|
.get("description")
|
|
.and_then(FrontmatterValue::as_scalar)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn command_source_name(source_commands: &Path, source_file: &Path) -> String {
|
|
source_file
|
|
.strip_prefix(source_commands)
|
|
.unwrap_or(source_file)
|
|
.with_extension("")
|
|
.components()
|
|
.filter_map(|component| component.as_os_str().to_str())
|
|
.collect::<Vec<_>>()
|
|
.join("-")
|
|
}
|
|
|
|
fn render_command_skill(body: &str, name: &str, description: &str, source_name: &str) -> String {
|
|
let body = rewrite_external_agent_terms(body.trim());
|
|
let template_body = if body.is_empty() {
|
|
"No command template body was found.".to_string()
|
|
} else {
|
|
body
|
|
};
|
|
format!(
|
|
"---\nname: {}\ndescription: {}\n---\n\n# {name}\n\nUse this skill when the user asks to run the migrated source command `{source_name}`.\n\n## Command Template\n\n{template_body}\n",
|
|
yaml_string(name),
|
|
yaml_string(&rewrite_external_agent_terms(description)),
|
|
)
|
|
}
|
|
|
|
fn has_unsupported_command_template_features(template: &str) -> bool {
|
|
template.contains("$ARGUMENTS")
|
|
|| contains_numbered_argument_placeholder(template)
|
|
|| (template.contains("{{") && template.contains("}}"))
|
|
|| template.contains("!`")
|
|
|| template.contains("! `")
|
|
|| template
|
|
.split_whitespace()
|
|
.any(|token| token.strip_prefix('@').is_some_and(|rest| !rest.is_empty()))
|
|
}
|
|
|
|
fn contains_numbered_argument_placeholder(template: &str) -> bool {
|
|
let bytes = template.as_bytes();
|
|
bytes
|
|
.windows(2)
|
|
.any(|window| window[0] == b'$' && window[1].is_ascii_digit())
|
|
}
|
|
|
|
fn frontmatter_string(
|
|
frontmatter: &BTreeMap<String, FrontmatterValue>,
|
|
key: &str,
|
|
) -> Option<String> {
|
|
frontmatter
|
|
.get(key)
|
|
.and_then(FrontmatterValue::as_scalar)
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn map_agent_reasoning_effort(effort: &str) -> Option<String> {
|
|
let mapped = match effort {
|
|
"max" => "xhigh".to_string(),
|
|
_ => effort.to_string(),
|
|
};
|
|
matches!(
|
|
mapped.as_str(),
|
|
"none" | "minimal" | "low" | "medium" | "high" | "xhigh"
|
|
)
|
|
.then_some(mapped)
|
|
}
|
|
|
|
fn map_agent_permission_mode(permission_mode: &str) -> Option<&'static str> {
|
|
match permission_mode {
|
|
"acceptEdits" => Some("workspace-write"),
|
|
"readOnly" => Some("read-only"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn json_string_vec(value: &JsonValue) -> Vec<String> {
|
|
match value {
|
|
JsonValue::Array(values) => values.iter().filter_map(json_string).collect(),
|
|
_ => json_string(value).into_iter().collect(),
|
|
}
|
|
}
|
|
|
|
fn json_string(value: &JsonValue) -> Option<String> {
|
|
match value {
|
|
JsonValue::Null => None,
|
|
JsonValue::String(value) => Some(value.clone()),
|
|
JsonValue::Bool(value) => Some(value.to_string()),
|
|
JsonValue::Number(value) => Some(value.to_string()),
|
|
JsonValue::Array(_) | JsonValue::Object(_) => None,
|
|
}
|
|
}
|
|
|
|
fn json_u64(value: &JsonValue) -> Option<u64> {
|
|
if value.is_boolean() || value.is_null() {
|
|
return None;
|
|
}
|
|
value.as_u64().or_else(|| value.as_str()?.parse().ok())
|
|
}
|
|
|
|
fn yaml_string(value: &str) -> String {
|
|
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
|
}
|
|
|
|
fn slugify_name(value: &str) -> String {
|
|
let mut slug = String::new();
|
|
let mut last_was_dash = false;
|
|
for ch in value.chars() {
|
|
if ch.is_ascii_alphanumeric() {
|
|
slug.push(ch.to_ascii_lowercase());
|
|
last_was_dash = false;
|
|
} else if !last_was_dash {
|
|
slug.push('-');
|
|
last_was_dash = true;
|
|
}
|
|
}
|
|
|
|
let slug = slug.trim_matches('-').to_string();
|
|
if slug.is_empty() {
|
|
"migrated".to_string()
|
|
} else {
|
|
slug
|
|
}
|
|
}
|
|
|
|
impl FrontmatterValue {
|
|
fn as_scalar(&self) -> Option<&str> {
|
|
match self {
|
|
Self::Scalar(value) => Some(value),
|
|
Self::Other => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_missing_or_empty_text_file(path: &Path) -> io::Result<bool> {
|
|
if !path.exists() {
|
|
return Ok(true);
|
|
}
|
|
if !path.is_file() {
|
|
return Ok(false);
|
|
}
|
|
|
|
Ok(fs::read_to_string(path)?.trim().is_empty())
|
|
}
|
|
|
|
fn rewrite_external_agent_terms(content: &str) -> String {
|
|
let mut rewritten = replace_case_insensitive_with_boundaries(
|
|
content,
|
|
&external_agent_doc_file_name(),
|
|
"AGENTS.md",
|
|
);
|
|
for from in external_agent_term_variants() {
|
|
rewritten = replace_case_insensitive_with_boundaries(&rewritten, &from, "Codex");
|
|
}
|
|
rewritten
|
|
}
|
|
|
|
fn replace_case_insensitive_with_boundaries(
|
|
input: &str,
|
|
needle: &str,
|
|
replacement: &str,
|
|
) -> String {
|
|
let needle_lower = needle.to_ascii_lowercase();
|
|
if needle_lower.is_empty() {
|
|
return input.to_string();
|
|
}
|
|
|
|
let haystack_lower = input.to_ascii_lowercase();
|
|
let bytes = input.as_bytes();
|
|
let mut output = String::with_capacity(input.len());
|
|
let mut last_emitted = 0usize;
|
|
let mut search_start = 0usize;
|
|
|
|
while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) {
|
|
let start = search_start + relative_pos;
|
|
let end = start + needle_lower.len();
|
|
let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
|
|
let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
|
|
|
|
if boundary_before && boundary_after {
|
|
output.push_str(&input[last_emitted..start]);
|
|
output.push_str(replacement);
|
|
last_emitted = end;
|
|
}
|
|
|
|
search_start = start + 1;
|
|
}
|
|
|
|
if last_emitted == 0 {
|
|
return input.to_string();
|
|
}
|
|
|
|
output.push_str(&input[last_emitted..]);
|
|
output
|
|
}
|
|
|
|
fn is_word_byte(byte: u8) -> bool {
|
|
byte.is_ascii_alphanumeric() || byte == b'_'
|
|
}
|
|
|
|
fn invalid_data_error(message: impl Into<String>) -> io::Error {
|
|
io::Error::new(io::ErrorKind::InvalidData, message.into())
|
|
}
|
|
|
|
fn external_agent_config_dir() -> String {
|
|
format!(".{SOURCE_EXTERNAL_AGENT_NAME}")
|
|
}
|
|
|
|
fn external_agent_project_config_file() -> String {
|
|
format!(".{SOURCE_EXTERNAL_AGENT_NAME}.json")
|
|
}
|
|
|
|
fn external_agent_project_dir_env_var() -> String {
|
|
format!(
|
|
"{}_PROJECT_DIR",
|
|
SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase()
|
|
)
|
|
}
|
|
|
|
fn external_agent_doc_file_name() -> String {
|
|
format!("{SOURCE_EXTERNAL_AGENT_NAME}.md")
|
|
}
|
|
|
|
fn external_agent_term_variants() -> [String; 5] {
|
|
[
|
|
format!("{SOURCE_EXTERNAL_AGENT_NAME} code"),
|
|
format!("{SOURCE_EXTERNAL_AGENT_NAME}-code"),
|
|
format!("{SOURCE_EXTERNAL_AGENT_NAME}_code"),
|
|
format!("{SOURCE_EXTERNAL_AGENT_NAME}code"),
|
|
SOURCE_EXTERNAL_AGENT_NAME.to_string(),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn source_path(relative_path: &str) -> PathBuf {
|
|
Path::new("/repo")
|
|
.join(external_agent_config_dir())
|
|
.join(relative_path)
|
|
}
|
|
|
|
fn source_hook_command(script_name: &str) -> String {
|
|
format!(
|
|
"python3 {}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}",
|
|
external_agent_config_dir()
|
|
)
|
|
}
|
|
|
|
fn source_hook_command_with_project_dir(script_name: &str) -> String {
|
|
format!(
|
|
"python3 \"${}\"/{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}",
|
|
external_agent_project_dir_env_var(),
|
|
external_agent_config_dir()
|
|
)
|
|
}
|
|
|
|
fn migrated_hook_command(script_name: &str) -> String {
|
|
migrated_quoted_hook_command(script_name)
|
|
}
|
|
|
|
fn migrated_quoted_hook_command(script_name: &str) -> String {
|
|
let hook_path = Path::new("/repo/.codex")
|
|
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
|
|
.join(script_name);
|
|
format!(
|
|
"python3 {}",
|
|
shell_single_quote(hook_path.to_string_lossy().as_ref())
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn env_placeholder_accepts_defaults() {
|
|
assert_eq!(
|
|
parse_env_placeholder("${TOKEN:-fallback}"),
|
|
Some("TOKEN".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_migration_skips_placeholder_args() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
fs::write(
|
|
root.path().join(".mcp.json"),
|
|
r#"{"mcpServers":{"db":{"command":"db-server","args":["${DATABASE_URL}"]}}}"#,
|
|
)
|
|
.expect("write mcp");
|
|
|
|
assert_eq!(
|
|
build_mcp_config_from_external(
|
|
root.path(),
|
|
/*external_agent_home*/ None,
|
|
/*settings*/ None,
|
|
)
|
|
.unwrap(),
|
|
TomlValue::Table(Default::default())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_migration_skips_unsupported_transports() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
fs::write(
|
|
root.path().join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"legacy-sse": {"type": "sse", "url": "https://example.invalid/sse"},
|
|
"vault": {
|
|
"url": "https://example.invalid/vault",
|
|
"headers": {"Authorization": "Bearer ${VAULT_TOKEN:-dev-token}"}
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write mcp");
|
|
|
|
assert_eq!(
|
|
build_mcp_config_from_external(
|
|
root.path(),
|
|
/*external_agent_home*/ None,
|
|
/*settings*/ None,
|
|
)
|
|
.unwrap(),
|
|
toml::from_str(
|
|
r#"
|
|
[mcp_servers.vault]
|
|
url = "https://example.invalid/vault"
|
|
bearer_token_env_var = "VAULT_TOKEN"
|
|
"#
|
|
)
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_migration_reads_matching_project_entries_from_repo_external_project_config() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
let project = root.path().join("repo");
|
|
fs::create_dir_all(&project).expect("create repo");
|
|
let other = root.path().join("other");
|
|
fs::create_dir_all(&other).expect("create other");
|
|
fs::write(
|
|
project.join(external_agent_project_config_file()),
|
|
serde_json::json!({
|
|
"mcpServers": {
|
|
"top": {"command": "top-server"}
|
|
},
|
|
"projects": {
|
|
project.display().to_string(): {
|
|
"mcpServers": {
|
|
"repo": {"command": "repo-server"}
|
|
}
|
|
},
|
|
other.display().to_string(): {
|
|
"mcpServers": {
|
|
"other": {"command": "other-server"}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write external agent project config");
|
|
|
|
assert_eq!(
|
|
build_mcp_config_from_external(
|
|
&project, /*external_agent_home*/ None, /*settings*/ None,
|
|
)
|
|
.unwrap(),
|
|
toml::from_str(
|
|
r#"
|
|
[mcp_servers.repo]
|
|
command = "repo-server"
|
|
|
|
[mcp_servers.top]
|
|
command = "top-server"
|
|
"#
|
|
)
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_migration_reads_matching_project_entries_from_home_external_project_config() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
let project = root.path().join("repo");
|
|
fs::create_dir_all(&project).expect("create repo");
|
|
let external_agent_home = root.path().join(external_agent_config_dir());
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
root.path().join(external_agent_project_config_file()),
|
|
serde_json::json!({
|
|
"projects": {
|
|
project.display().to_string(): {
|
|
"mcpServers": {
|
|
"repo": {"command": "repo-server"}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write external agent project config");
|
|
|
|
assert_eq!(
|
|
build_mcp_config_from_external(
|
|
&project,
|
|
Some(&external_agent_home),
|
|
/*settings*/ None,
|
|
)
|
|
.unwrap(),
|
|
toml::from_str(
|
|
r#"
|
|
[mcp_servers.repo]
|
|
command = "repo-server"
|
|
"#
|
|
)
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_migration_preserves_repo_servers_over_home_project_entries() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
let project = root.path().join("repo");
|
|
fs::create_dir_all(&project).expect("create repo");
|
|
let external_agent_home = root.path().join(external_agent_config_dir());
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
project.join(EXTERNAL_AGENT_MCP_CONFIG_FILE),
|
|
serde_json::json!({
|
|
"mcpServers": {
|
|
"shared": {"command": "repo-server"}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write repo mcp");
|
|
fs::write(
|
|
root.path().join(external_agent_project_config_file()),
|
|
serde_json::json!({
|
|
"projects": {
|
|
project.display().to_string(): {
|
|
"mcpServers": {
|
|
"home-only": {"command": "home-only-server"},
|
|
"shared": {"command": "home-server"}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write external agent project config");
|
|
|
|
assert_eq!(
|
|
build_mcp_config_from_external(
|
|
&project,
|
|
Some(&external_agent_home),
|
|
/*settings*/ None,
|
|
)
|
|
.unwrap(),
|
|
toml::from_str(
|
|
r#"
|
|
[mcp_servers.home-only]
|
|
command = "home-only-server"
|
|
|
|
[mcp_servers.shared]
|
|
command = "repo-server"
|
|
"#
|
|
)
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_migration_skips_disabled_servers() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
fs::write(
|
|
root.path().join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"enabled": {"command": "enabled-server"},
|
|
"explicit-disabled": {"command": "disabled-server", "disabled": true},
|
|
"not-enabled": {"command": "not-enabled-server"}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write mcp");
|
|
let settings = serde_json::json!({
|
|
"enabledMcpjsonServers": ["enabled"],
|
|
"disabledMcpjsonServers": ["explicit-disabled"]
|
|
});
|
|
|
|
assert_eq!(
|
|
build_mcp_config_from_external(
|
|
root.path(),
|
|
/*external_agent_home*/ None,
|
|
Some(&settings),
|
|
)
|
|
.unwrap(),
|
|
toml::from_str(
|
|
r#"
|
|
[mcp_servers.enabled]
|
|
command = "enabled-server"
|
|
"#
|
|
)
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_skill_names_include_nested_paths() {
|
|
let root = source_path("commands");
|
|
let file = source_path("commands/pr/review.md");
|
|
|
|
assert_eq!(command_skill_name(&root, &file), "source-command-pr-review");
|
|
}
|
|
|
|
#[test]
|
|
fn command_skill_names_must_fit_codex_skill_loader_limit() {
|
|
let root = source_path("commands");
|
|
let file = source_path("commands/this/is/a/deeply/nested/command/with/a/very/long/name.md");
|
|
let document = parse_document_content("---\ndescription: Review PR\n---\nReview\n");
|
|
|
|
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn commands_with_provider_runtime_expansion_are_skipped() {
|
|
let root = source_path("commands");
|
|
let file = source_path("commands/deploy.md");
|
|
let document = parse_document_content(
|
|
"---\ndescription: Deploy\n---\nDeploy $ARGUMENTS from @release.yaml\n",
|
|
);
|
|
|
|
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn commands_without_description_are_skipped() {
|
|
let root = source_path("commands");
|
|
let file = source_path("commands/README.md");
|
|
let document = parse_document_content("# Notes\n\nThis documents commands.\n");
|
|
|
|
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn command_slug_collisions_are_skipped() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
let commands = root.path().join("commands");
|
|
fs::create_dir_all(&commands).expect("create commands");
|
|
fs::write(
|
|
commands.join("foo-bar.md"),
|
|
"---\ndescription: First\n---\nRun the first command.\n",
|
|
)
|
|
.expect("write first command");
|
|
fs::write(
|
|
commands.join("foo_bar.md"),
|
|
"---\ndescription: Second\n---\nRun the second command.\n",
|
|
)
|
|
.expect("write second command");
|
|
|
|
assert_eq!(
|
|
unique_supported_command_sources(&commands).unwrap(),
|
|
Vec::<(PathBuf, String)>::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_accepts_yaml_block_lists_by_ignoring_unsupported_fields() {
|
|
let document = parse_document_content(
|
|
"---\nname: cloud-incident\ndescription: Debug incidents\nskills:\n - runbook-reader\ntools:\n - Read\n - Bash\ndisallowedTools:\n - Write\n---\nInvestigate carefully.\n",
|
|
);
|
|
|
|
assert!(agent_metadata(&document).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_requires_minimum_codex_agent_fields() {
|
|
let missing_description =
|
|
parse_document_content("---\nname: incomplete\n---\nInvestigate carefully.\n");
|
|
let missing_body =
|
|
parse_document_content("---\nname: incomplete\ndescription: Missing body\n---\n");
|
|
|
|
assert!(agent_metadata(&missing_description).is_none());
|
|
assert!(agent_metadata(&missing_body).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_preserves_default_model_when_source_model_is_present() {
|
|
let document = parse_document_content(
|
|
"---\nname: reviewer\ndescription: Review code\nmodel: source-opus\neffort: max\n---\nReview carefully.\n",
|
|
);
|
|
let metadata = agent_metadata(&document).expect("metadata");
|
|
let rendered: TomlValue =
|
|
toml::from_str(&render_agent_toml(&document.body, &metadata).expect("render agent"))
|
|
.expect("parse rendered agent");
|
|
let expected: TomlValue = toml::from_str(
|
|
r#"
|
|
name = "reviewer"
|
|
description = "Review code"
|
|
model_reasoning_effort = "xhigh"
|
|
developer_instructions = """
|
|
Review carefully."""
|
|
"#,
|
|
)
|
|
.expect("parse expected agent");
|
|
|
|
assert_eq!(rendered, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_target_preserves_dotted_file_stem() {
|
|
let target_agents = Path::new("/repo/.codex/agents");
|
|
let source_file = source_path("agents/security.audit.md");
|
|
|
|
assert_eq!(
|
|
subagent_target_file(&source_file, target_agents),
|
|
Some(PathBuf::from("/repo/.codex/agents/security.audit.toml"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn frontmatter_accepts_crlf_delimiters() {
|
|
let document = parse_document_content(
|
|
"---\r\nname: reviewer\r\ndescription: Review code\r\n---\r\nReview carefully.\r\n",
|
|
);
|
|
|
|
assert_eq!(
|
|
(
|
|
document
|
|
.frontmatter
|
|
.get("name")
|
|
.and_then(FrontmatterValue::as_scalar),
|
|
document
|
|
.frontmatter
|
|
.get("description")
|
|
.and_then(FrontmatterValue::as_scalar),
|
|
document.body.as_str(),
|
|
),
|
|
(
|
|
Some("reviewer"),
|
|
Some("Review code"),
|
|
"Review carefully.\r\n"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_migration_ignores_unsupported_handlers() {
|
|
let settings = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [{
|
|
"matcher": "Bash",
|
|
"if": "tool_input.command contains 'rm'",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": source_hook_command("policy_gate.py")
|
|
}]
|
|
}, {
|
|
"matcher": "Edit",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"if": "Bash(rm *)",
|
|
"command": source_hook_command("policy_gate.py")
|
|
},
|
|
{
|
|
"type": "http",
|
|
"url": "https://example.invalid/hook"
|
|
}
|
|
]
|
|
}],
|
|
"PermissionRequest": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": source_hook_command("approve.py")
|
|
}]
|
|
}],
|
|
"SubagentStart": [{
|
|
"matcher": "worker",
|
|
"hooks": [{"type": "prompt", "prompt": "check"}]
|
|
}]
|
|
}
|
|
});
|
|
let mut migration = serde_json::Map::new();
|
|
append_convertible_hook_groups(&settings, &mut migration, Some(Path::new("/repo/.codex")));
|
|
|
|
assert_eq!(
|
|
migration,
|
|
serde_json::json!({
|
|
"PermissionRequest": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": migrated_hook_command("approve.py")
|
|
}]
|
|
}]
|
|
})
|
|
.as_object()
|
|
.cloned()
|
|
.expect("object")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_migration_honors_disable_all_hooks() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
fs::write(
|
|
root.path().join("settings.json"),
|
|
r#"{
|
|
"disableAllHooks": true,
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"matcher": "startup",
|
|
"hooks": [{"type": "command", "command": "echo setup"}]
|
|
}]
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
|
|
assert_eq!(
|
|
hook_migration(root.path(), /*target_config_dir*/ None).unwrap(),
|
|
serde_json::Map::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_migration_honors_settings_local_disable_override() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
fs::write(
|
|
root.path().join("settings.json"),
|
|
r#"{
|
|
"disableAllHooks": true,
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"matcher": "project",
|
|
"hooks": [{"type": "command", "command": "echo project"}]
|
|
}]
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write project settings");
|
|
fs::write(
|
|
root.path().join("settings.local.json"),
|
|
r#"{
|
|
"disableAllHooks": false,
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"matcher": "local",
|
|
"hooks": [{"type": "command", "command": "echo local"}]
|
|
}]
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
assert_eq!(
|
|
hook_migration(root.path(), /*target_config_dir*/ None).unwrap(),
|
|
serde_json::json!({
|
|
"SessionStart": [{
|
|
"matcher": "project",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "echo project"
|
|
}]
|
|
}, {
|
|
"matcher": "local",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "echo local"
|
|
}]
|
|
}]
|
|
})
|
|
.as_object()
|
|
.cloned()
|
|
.expect("object")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_command_paths_rewrite_to_target_hook_dir() {
|
|
let project_dir_env_var = external_agent_project_dir_env_var();
|
|
let plugin_root_env_var = format!(
|
|
"{}_PLUGIN_ROOT",
|
|
SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase()
|
|
);
|
|
let source_hooks_path = format!(
|
|
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}",
|
|
external_agent_config_dir()
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&source_hook_command_with_project_dir("check.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_hook_command("check.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("\"${project_dir_env_var}\"/{source_hooks_path}/check-style.sh"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
shell_single_quote(
|
|
Path::new("/repo/.codex")
|
|
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
|
|
.join("check-style.sh")
|
|
.to_string_lossy()
|
|
.as_ref()
|
|
)
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&source_hook_command("check.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_hook_command("check.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("python3 ./{source_hooks_path}/check.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_hook_command("check.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/check.py'"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_quoted_hook_command("check.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("python3 \"${{{project_dir_env_var}}}/{source_hooks_path}/check.py\""),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_quoted_hook_command("check.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("bash -lc \"python3 {source_hooks_path}/check.py\""),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!("bash -lc \"python3 {source_hooks_path}/check.py\"")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!(
|
|
"HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\""
|
|
),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!(
|
|
"HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\""
|
|
)
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("python3 {source_hooks_path}/${{SCRIPT}}.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!("python3 {source_hooks_path}/${{SCRIPT}}.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("python3 {source_hooks_path}/{{lint,fmt}}.sh"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!("python3 {source_hooks_path}/{{lint,fmt}}.sh")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("python3 {source_hooks_path}/my\\ script.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!("python3 {source_hooks_path}/my\\ script.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("python3 .{SOURCE_EXTERNAL_AGENT_NAME}\\hooks\\check.py"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!("python3 .{}\\hooks\\check.py", SOURCE_EXTERNAL_AGENT_NAME)
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!(
|
|
"python3 \"%{}%\\{}\\hooks\\check.py\"",
|
|
project_dir_env_var,
|
|
external_agent_config_dir()
|
|
),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!(
|
|
"python3 \"%{}%\\{}\\hooks\\check.py\"",
|
|
project_dir_env_var,
|
|
external_agent_config_dir()
|
|
)
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/my script.py'"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
migrated_quoted_hook_command("my script.py")
|
|
);
|
|
assert_eq!(
|
|
rewrite_hook_command(
|
|
&format!("/repo/{source_hooks_path}/check.py 2>/dev/null || true"),
|
|
Some(Path::new("/repo/.codex")),
|
|
),
|
|
format!(
|
|
"{} 2>/dev/null || true",
|
|
shell_single_quote(
|
|
Path::new("/repo/.codex")
|
|
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
|
|
.join("check.py")
|
|
.to_string_lossy()
|
|
.as_ref()
|
|
)
|
|
)
|
|
);
|
|
let plugin_script_command = format!("${{{plugin_root_env_var}}}/scripts/format.sh");
|
|
assert_eq!(
|
|
rewrite_hook_command(&plugin_script_command, Some(Path::new("/repo/.codex")),),
|
|
plugin_script_command
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_script_copy_keeps_existing_target_scripts() {
|
|
let root = tempfile::TempDir::new().expect("tempdir");
|
|
let source_external_agent_dir = root.path().join(external_agent_config_dir());
|
|
let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR);
|
|
let target_config_dir = root.path().join(".codex");
|
|
let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
|
|
fs::create_dir_all(&source_hooks).expect("create source hooks");
|
|
fs::create_dir_all(&target_hooks).expect("create target hooks");
|
|
fs::write(source_hooks.join("check.py"), "new script").expect("write source hook");
|
|
fs::write(target_hooks.join("check.py"), "existing script").expect("write target hook");
|
|
|
|
copy_hook_scripts(&source_external_agent_dir, &target_config_dir).expect("copy hooks");
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(target_hooks.join("check.py")).expect("read target hook"),
|
|
"existing script"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_migration_drops_negative_timeouts() {
|
|
let settings = serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"matcher": "startup",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "echo setup",
|
|
"timeout": -1
|
|
}]
|
|
}]
|
|
}
|
|
});
|
|
let mut migration = serde_json::Map::new();
|
|
append_convertible_hook_groups(&settings, &mut migration, /*target_config_dir*/ None);
|
|
|
|
assert_eq!(
|
|
migration,
|
|
serde_json::json!({
|
|
"SessionStart": [{
|
|
"matcher": "startup",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "echo setup"
|
|
}]
|
|
}]
|
|
})
|
|
.as_object()
|
|
.cloned()
|
|
.expect("object")
|
|
);
|
|
}
|
|
}
|