pub mod manager;
use super::slots::render_slots;
pub const HOOK_COMMAND_SLOT: &str = "__TAPES_HOOK_COMMAND__";
pub const HOOKS_MANIFEST_TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/codex-app/hooks.json"
));
pub const PLUGIN_MANIFEST_TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/codex-app/plugin.json"
));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct HookPluginTemplates {
pub plugin_manifest: &'static str,
pub hooks_manifest: &'static str,
}
pub const CODEX_APP_TEMPLATES: HookPluginTemplates = HookPluginTemplates {
plugin_manifest: PLUGIN_MANIFEST_TEMPLATE,
hooks_manifest: HOOKS_MANIFEST_TEMPLATE,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct HookPluginIdentity<'a> {
pub name: &'a str,
pub version: &'a str,
pub description: &'a str,
pub display_name: &'a str,
pub short_description: &'a str,
pub long_description: &'a str,
pub developer_name: &'a str,
}
impl<'a> HookPluginIdentity<'a> {
#[must_use]
pub const fn new(name: &'a str, version: &'a str) -> Self {
Self {
name,
version,
description: name,
display_name: name,
short_description: name,
long_description: name,
developer_name: name,
}
}
#[must_use]
pub const fn with_description(mut self, description: &'a str) -> Self {
self.description = description;
self
}
#[must_use]
pub const fn with_display_name(mut self, display_name: &'a str) -> Self {
self.display_name = display_name;
self
}
#[must_use]
pub const fn with_short_description(mut self, short_description: &'a str) -> Self {
self.short_description = short_description;
self
}
#[must_use]
pub const fn with_long_description(mut self, long_description: &'a str) -> Self {
self.long_description = long_description;
self
}
#[must_use]
pub const fn with_developer_name(mut self, developer_name: &'a str) -> Self {
self.developer_name = developer_name;
self
}
fn slots(&self) -> [(&'static str, &str); 7] {
[
("__TAPES_PLUGIN_NAME__", self.name),
("__TAPES_PLUGIN_VERSION__", self.version),
("__TAPES_PLUGIN_DESCRIPTION__", self.description),
("__TAPES_PLUGIN_DISPLAY_NAME__", self.display_name),
("__TAPES_PLUGIN_SHORT_DESCRIPTION__", self.short_description),
("__TAPES_PLUGIN_LONG_DESCRIPTION__", self.long_description),
("__TAPES_PLUGIN_DEVELOPER_NAME__", self.developer_name),
]
}
}
#[must_use]
pub fn render_hooks_manifest(hook_command: &str) -> String {
render_slots(
HOOKS_MANIFEST_TEMPLATE,
&[(HOOK_COMMAND_SLOT, hook_command)],
)
}
#[must_use]
pub fn render_plugin_manifest(identity: &HookPluginIdentity) -> String {
render_slots(PLUGIN_MANIFEST_TEMPLATE, &identity.slots())
}
#[must_use]
pub fn shell_quote(value: &str) -> String {
let safe =
|character: char| character.is_ascii_alphanumeric() || "._-/@:+,=".contains(character);
if !value.is_empty() && value.chars().all(safe) {
return value.to_owned();
}
format!("'{}'", value.replace('\'', r"'\''"))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::attribution::codex_app::LIFECYCLE_EVENTS;
use std::collections::BTreeMap;
fn identity() -> HookPluginIdentity<'static> {
HookPluginIdentity::new("acme-codex", "0.1.0")
.with_description("Keeps Codex connected to acmed.")
.with_display_name("Acme for Codex")
.with_short_description("Keep Codex connected to Acme.")
.with_long_description("Forwards lifecycle metadata to local acmed.")
.with_developer_name("Acme")
}
#[test]
fn a_minimal_identity_fills_every_slot_with_the_plugin_name() {
let rendered = render_plugin_manifest(&HookPluginIdentity::new("bare-codex", "2.0.0"));
let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert!(
!rendered.contains("__TAPES_"),
"a slot survived a minimal render: {rendered}"
);
assert_eq!(parsed["name"], "bare-codex");
assert_eq!(parsed["version"], "2.0.0");
assert_eq!(parsed["interface"]["displayName"], "bare-codex");
assert_eq!(parsed["interface"]["developerName"], "bare-codex");
assert_eq!(parsed["author"]["name"], "bare-codex");
}
#[test]
fn each_setter_reaches_its_own_slot() {
let identity = HookPluginIdentity::new("n", "v")
.with_description("d")
.with_display_name("dn")
.with_short_description("sd")
.with_long_description("ld")
.with_developer_name("dev");
assert_eq!(
identity.slots().map(|(_, value)| value),
["n", "v", "d", "dn", "sd", "ld", "dev"],
);
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct HookFile {
hooks: BTreeMap<String, Vec<HookRegistration>>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct HookRegistration {
hooks: Vec<CommandHook>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CommandHook {
#[serde(rename = "type")]
kind: String,
command: String,
}
#[test]
fn the_rendered_hooks_manifest_subscribes_the_command_to_every_lifecycle_event() {
let command = r#"/bin/sh "${PLUGIN_ROOT}/scripts/capture-hook""#;
let rendered = render_hooks_manifest(command);
let parsed: HookFile = serde_json::from_str(&rendered).unwrap();
let mut events: Vec<&str> = parsed.hooks.keys().map(String::as_str).collect();
let mut expected: Vec<&str> = LIFECYCLE_EVENTS.to_vec();
events.sort_unstable();
expected.sort_unstable();
assert_eq!(events, expected);
for (event, registrations) in &parsed.hooks {
assert_eq!(registrations.len(), 1, "{event} has multiple registrations");
assert_eq!(registrations[0].hooks.len(), 1);
assert_eq!(registrations[0].hooks[0].kind, "command");
assert_eq!(
registrations[0].hooks[0].command, command,
"{event}'s command did not survive rendering byte-exact"
);
}
}
#[test]
fn a_value_containing_another_slots_placeholder_survives_verbatim() {
let mut identity = identity();
identity.name = "__TAPES_PLUGIN_VERSION__";
identity.long_description = "mentions __TAPES_PLUGIN_NAME__ in prose";
let rendered = render_plugin_manifest(&identity);
let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(
parsed["name"], "__TAPES_PLUGIN_VERSION__",
"the name was re-substituted as if it were template text"
);
assert_eq!(
parsed["interface"]["longDescription"],
"mentions __TAPES_PLUGIN_NAME__ in prose"
);
assert_eq!(parsed["version"], "0.1.0");
assert_eq!(parsed["interface"]["displayName"], "Acme for Codex");
}
#[test]
fn a_command_containing_the_slot_spelling_survives_verbatim() {
let command = "run --note '\"__TAPES_HOOK_COMMAND__\"'";
let rendered = render_hooks_manifest(command);
let parsed: HookFile = serde_json::from_str(&rendered).unwrap();
for registrations in parsed.hooks.values() {
assert_eq!(registrations[0].hooks[0].command, command);
}
}
#[test]
fn rendering_escapes_the_command_as_a_json_string() {
let command = "C:\\tools\\hook.exe --label \"two words\"\twith\ncontrol\u{1}chars";
let rendered = render_hooks_manifest(command);
let parsed: HookFile = serde_json::from_str(&rendered).unwrap();
let registrations = parsed.hooks.get("Stop").unwrap();
assert_eq!(registrations[0].hooks[0].command, command);
}
#[test]
fn the_rendered_plugin_manifest_carries_the_identity_and_no_slots() {
let rendered = render_plugin_manifest(&identity());
let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(parsed["name"], "acme-codex");
assert_eq!(parsed["version"], "0.1.0");
assert_eq!(parsed["author"]["name"], "Acme");
assert_eq!(parsed["interface"]["displayName"], "Acme for Codex");
assert_eq!(parsed["interface"]["developerName"], "Acme");
assert!(
!rendered.contains("__TAPES_"),
"an identity slot survived rendering: {rendered}"
);
for absent in ["hooks", "tools", "apps", "skills"] {
assert!(
parsed.get(absent).is_none(),
"the manifest unexpectedly declares {absent:?}"
);
}
}
#[test]
fn identity_slots_and_template_slots_cover_each_other() {
for (slot, _) in identity().slots() {
assert!(
PLUGIN_MANIFEST_TEMPLATE.contains(&format!("\"{slot}\"")),
"template is missing slot {slot}"
);
}
assert_eq!(
PLUGIN_MANIFEST_TEMPLATE.matches("__TAPES_").count(),
identity().slots().len() + 1, );
assert_eq!(
HOOKS_MANIFEST_TEMPLATE.matches("__TAPES_").count(),
LIFECYCLE_EVENTS.len(),
"the hooks template must carry exactly one command slot per event"
);
assert!(HOOKS_MANIFEST_TEMPLATE.contains(&format!("\"{HOOK_COMMAND_SLOT}\"")));
}
#[test]
fn the_templates_carry_no_vendor_branding() {
for template in [PLUGIN_MANIFEST_TEMPLATE, HOOKS_MANIFEST_TEMPLATE] {
let lowered = template.to_ascii_lowercase();
for token in ["paper", "papercompute"] {
assert!(
!lowered.contains(token),
"a crate-owned template mentions {token:?}"
);
}
}
}
#[test]
fn the_templates_have_no_built_in_endpoint() {
for template in [PLUGIN_MANIFEST_TEMPLATE, HOOKS_MANIFEST_TEMPLATE] {
for literal in ["127.0.0.1:", "localhost:", "http://"] {
assert!(
!template.contains(literal),
"a template hard-codes {literal:?}"
);
}
}
}
#[test]
fn the_registry_reaches_these_templates() {
let harness = crate::harness::find("codex-app").expect("codex-app is registered");
match harness.plugin() {
crate::harness::PluginDelivery::HookManifestTemplates(templates) => {
assert_eq!(*templates, CODEX_APP_TEMPLATES);
}
other => panic!("codex-app declares {other:?}, not hook manifest templates"),
}
}
#[cfg(unix)]
#[test]
fn a_quoted_value_returns_from_the_shell_as_one_unchanged_word() {
for value in [
"/tmp/plain/path",
"acme-codex@acme",
"",
"/tmp/two words/plugin",
"/tmp/it's here/plugin",
"/tmp/$HOME/plugin",
"/tmp/`whoami`/plugin",
"/tmp/a;rm -rf b/plugin",
"/tmp/new\nline/plugin",
"/tmp/glob*?[x]/plugin",
"~/not-expanded",
"/tmp/\u{e9}t\u{e9}/plugin",
] {
let output = std::process::Command::new("/bin/sh")
.arg("-c")
.arg(format!("printf '%s' {}", shell_quote(value)))
.output()
.unwrap();
assert!(
output.status.success(),
"{value:?} produced unparseable shell text: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
value,
"{value:?} did not survive the shell"
);
}
}
#[test]
fn only_values_needing_quotes_get_them() {
for bare in ["plugin", "acme-codex@acme", "/a/b_c.d-e", "K=V", "a:b+c,d"] {
assert_eq!(shell_quote(bare), bare);
}
for quoted in [
"", " ", "a b", "a~b", "a*b", "a$b", "a'b", "a\\b", "a#b", "a%b",
] {
assert!(
shell_quote(quoted).starts_with('\''),
"{quoted:?} was left unquoted"
);
}
}
}