use std::path::PathBuf;
use tapes_capture::envelope::{
HARNESS_ID_CLAUDE, HARNESS_ID_CODEX, HARNESS_ID_CODEX_APP, HARNESS_ID_OPENCODE, HARNESS_ID_PI,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UserAgentMatch {
None,
Prefix(&'static str),
}
impl UserAgentMatch {
#[must_use]
pub fn matches(&self, ua: &str) -> bool {
match self {
Self::None => false,
Self::Prefix(prefix) => ua.to_ascii_lowercase().starts_with(prefix),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LaunchSupport {
Recipe,
ConsumerOwned,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AttributionStrategy {
SessionsDir,
OpenRollout,
SelfAttributing,
LifecycleHooks,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TranscriptSource {
ClaudeProjects,
CodexRollouts,
None,
}
impl TranscriptSource {
#[must_use]
pub fn resolve(&self) -> Option<PathBuf> {
match self {
Self::None => None,
Self::ClaudeProjects => dirs::home_dir().map(|h| h.join(".claude").join("projects")),
Self::CodexRollouts => crate::attribution::codex::session::default_sessions_dir(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PluginDelivery {
None,
BundledExtension(&'static [crate::plugin::PluginArtifact]),
HookManifestTemplates(&'static crate::plugin::codex_app::HookPluginTemplates),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Harness {
id: &'static str,
aliases: &'static [&'static str],
user_agent: UserAgentMatch,
launch: LaunchSupport,
attribution: AttributionStrategy,
transcripts: TranscriptSource,
plugin: PluginDelivery,
}
impl Harness {
#[must_use]
pub const fn id(&self) -> &'static str {
self.id
}
#[must_use]
pub const fn aliases(&self) -> &'static [&'static str] {
self.aliases
}
#[must_use]
pub fn matches_name(&self, name: &str) -> bool {
let name = name.trim();
self.id.eq_ignore_ascii_case(name)
|| self
.aliases
.iter()
.any(|alias| alias.eq_ignore_ascii_case(name))
}
#[must_use]
pub const fn user_agent(&self) -> UserAgentMatch {
self.user_agent
}
#[must_use]
pub fn matches_user_agent(&self, ua: &str) -> bool {
self.user_agent.matches(ua)
}
#[must_use]
pub const fn launch(&self) -> LaunchSupport {
self.launch
}
#[must_use]
pub const fn is_launchable(&self) -> bool {
!matches!(self.launch, LaunchSupport::Unsupported)
}
#[must_use]
pub const fn attribution(&self) -> AttributionStrategy {
self.attribution
}
#[must_use]
pub const fn transcripts(&self) -> TranscriptSource {
self.transcripts
}
#[must_use]
pub fn transcript_root(&self) -> Option<PathBuf> {
self.transcripts.resolve()
}
#[must_use]
pub const fn plugin(&self) -> PluginDelivery {
self.plugin
}
#[must_use]
pub const fn plugin_artifacts(&self) -> &'static [crate::plugin::PluginArtifact] {
match self.plugin {
PluginDelivery::None | PluginDelivery::HookManifestTemplates(_) => &[],
PluginDelivery::BundledExtension(artifacts) => artifacts,
}
}
}
pub const CLAUDE: Harness = Harness {
id: HARNESS_ID_CLAUDE,
aliases: &["claude-code"],
user_agent: UserAgentMatch::Prefix("claude"),
launch: LaunchSupport::Recipe,
attribution: AttributionStrategy::SessionsDir,
transcripts: TranscriptSource::ClaudeProjects,
plugin: PluginDelivery::None,
};
pub const CODEX: Harness = Harness {
id: HARNESS_ID_CODEX,
aliases: &[],
user_agent: UserAgentMatch::None,
launch: LaunchSupport::Recipe,
attribution: AttributionStrategy::OpenRollout,
transcripts: TranscriptSource::CodexRollouts,
plugin: PluginDelivery::None,
};
pub const CODEX_APP: Harness = Harness {
id: HARNESS_ID_CODEX_APP,
aliases: &["codex-desktop"],
user_agent: UserAgentMatch::None,
launch: LaunchSupport::Unsupported,
attribution: AttributionStrategy::LifecycleHooks,
transcripts: TranscriptSource::CodexRollouts,
plugin: PluginDelivery::HookManifestTemplates(&crate::plugin::codex_app::CODEX_APP_TEMPLATES),
};
pub const OPENCODE: Harness = Harness {
id: HARNESS_ID_OPENCODE,
aliases: &[],
user_agent: UserAgentMatch::None,
launch: LaunchSupport::Recipe,
attribution: AttributionStrategy::SelfAttributing,
transcripts: TranscriptSource::None,
plugin: PluginDelivery::BundledExtension(crate::plugin::OPENCODE_ARTIFACTS),
};
pub const PI: Harness = Harness {
id: HARNESS_ID_PI,
aliases: &[],
user_agent: UserAgentMatch::None,
launch: LaunchSupport::ConsumerOwned,
attribution: AttributionStrategy::SelfAttributing,
transcripts: TranscriptSource::None,
plugin: PluginDelivery::BundledExtension(crate::plugin::PI_ARTIFACTS),
};
pub const REGISTRY: &[Harness] = &[CLAUDE, CODEX, CODEX_APP, OPENCODE, PI];
#[must_use]
pub fn all() -> &'static [Harness] {
REGISTRY
}
#[must_use]
pub fn find(name: &str) -> Option<&'static Harness> {
REGISTRY.iter().find(|harness| harness.matches_name(name))
}
#[must_use]
pub fn for_user_agent(ua: &str) -> Option<&'static Harness> {
REGISTRY
.iter()
.find(|harness| harness.matches_user_agent(ua))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RegistryUserAgents;
impl crate::attribution::pipeline::UserAgentHarness for RegistryUserAgents {
fn harness_id(&self, user_agent: &str) -> Option<&'static str> {
for_user_agent(user_agent).map(Harness::id)
}
}
#[must_use]
pub fn supported_agents() -> Vec<&'static str> {
REGISTRY
.iter()
.filter(|harness| harness.is_launchable())
.map(Harness::id)
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn user_agent_rules_are_pairwise_disjoint() {
let prefixes: Vec<&str> = REGISTRY
.iter()
.filter_map(|harness| match harness.user_agent() {
UserAgentMatch::Prefix(prefix) => Some(prefix),
UserAgentMatch::None => None,
})
.collect();
for (i, a) in prefixes.iter().enumerate() {
for b in prefixes.iter().skip(i + 1) {
assert!(
!a.starts_with(b) && !b.starts_with(a),
"User-Agent prefixes {a:?} and {b:?} overlap; \
for_user_agent would resolve by registry order"
);
}
}
}
#[test]
fn every_name_in_the_registry_is_unique() {
let mut names: Vec<String> = Vec::new();
for harness in REGISTRY {
names.push(harness.id().to_ascii_lowercase());
names.extend(harness.aliases().iter().map(|a| a.to_ascii_lowercase()));
}
let mut sorted = names.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), names.len(), "duplicate name in: {names:?}");
}
#[test]
fn registry_ids_are_the_envelope_ids() {
assert_eq!(CLAUDE.id(), tapes_capture::envelope::HARNESS_ID_CLAUDE);
assert_eq!(CODEX.id(), tapes_capture::envelope::HARNESS_ID_CODEX);
assert_eq!(
CODEX_APP.id(),
tapes_capture::envelope::HARNESS_ID_CODEX_APP
);
assert_eq!(OPENCODE.id(), tapes_capture::envelope::HARNESS_ID_OPENCODE);
assert_eq!(PI.id(), tapes_capture::envelope::HARNESS_ID_PI);
for harness in REGISTRY {
assert_ne!(harness.id(), tapes_capture::envelope::HARNESS_ID_UNKNOWN);
}
}
#[test]
fn names_resolve_case_insensitively_through_aliases() {
assert_eq!(find("claude").map(Harness::id), Some("claude"));
assert_eq!(find("CLAUDE").map(Harness::id), Some("claude"));
assert_eq!(find(" claude-code ").map(Harness::id), Some("claude"));
assert_eq!(find("codex").map(Harness::id), Some("codex"));
assert_eq!(find("codex-app").map(Harness::id), Some("codex-app"));
assert_eq!(find("codex-desktop").map(Harness::id), Some("codex-app"));
assert!(find("gemini").is_none());
assert!(find("").is_none());
}
#[test]
fn codex_app_is_the_lifecycle_hooks_variant() {
assert_eq!(CODEX_APP.launch(), LaunchSupport::Unsupported);
assert!(!CODEX_APP.is_launchable());
assert!(!supported_agents().contains(&"codex-app"));
assert_eq!(CODEX_APP.attribution(), AttributionStrategy::LifecycleHooks);
assert_eq!(CODEX_APP.transcripts(), CODEX.transcripts());
assert!(CODEX_APP.transcript_root().is_some());
assert!(matches!(
CODEX_APP.plugin(),
PluginDelivery::HookManifestTemplates(_)
));
assert!(CODEX_APP.plugin_artifacts().is_empty());
let hook_attributed: Vec<&str> = REGISTRY
.iter()
.filter(|h| h.attribution() == AttributionStrategy::LifecycleHooks)
.map(Harness::id)
.collect();
assert_eq!(hook_attributed, vec!["codex-app"]);
}
#[test]
fn the_user_agent_gate_is_a_prefix_not_a_substring() {
assert_eq!(
for_user_agent("claude-cli/2.1.145").map(Harness::id),
Some("claude")
);
assert_eq!(
for_user_agent("Claude-CLI/2.1.145").map(Harness::id),
Some("claude")
);
assert_eq!(
for_user_agent("CLAUDE/0.0").map(Harness::id),
Some("claude")
);
assert!(for_user_agent("curl/8.0").is_none());
assert!(for_user_agent("OpenAI/python").is_none());
assert!(for_user_agent("").is_none());
assert!(for_user_agent("some-claude-like").is_none());
}
#[test]
fn harnesses_without_a_user_agent_rule_claim_nothing() {
for harness in REGISTRY {
if harness.user_agent() == UserAgentMatch::None {
assert!(!harness.matches_user_agent(harness.id()));
assert!(!harness.matches_user_agent("anything at all"));
}
}
}
#[test]
fn supported_agents_is_the_launchable_subset_in_registry_order() {
assert_eq!(
supported_agents(),
vec!["claude", "codex", "opencode", "pi"]
);
assert_eq!(PI.launch(), LaunchSupport::ConsumerOwned);
assert_eq!(CLAUDE.launch(), LaunchSupport::Recipe);
}
#[test]
fn pi_is_the_self_attributing_variant() {
assert_eq!(PI.attribution(), AttributionStrategy::SelfAttributing);
assert!(matches!(PI.plugin(), PluginDelivery::BundledExtension(_)));
assert_eq!(PI.transcripts(), TranscriptSource::None);
let self_attributing: Vec<&str> = REGISTRY
.iter()
.filter(|h| h.attribution() == AttributionStrategy::SelfAttributing)
.map(Harness::id)
.collect();
assert_eq!(self_attributing, vec!["opencode", "pi"]);
}
#[test]
fn opencode_is_self_attributing_and_still_recipe_launchable() {
assert_eq!(OPENCODE.launch(), LaunchSupport::Recipe);
assert!(supported_agents().contains(&"opencode"));
assert_eq!(OPENCODE.attribution(), AttributionStrategy::SelfAttributing);
assert!(matches!(
OPENCODE.plugin(),
PluginDelivery::BundledExtension(_)
));
assert_eq!(OPENCODE.transcripts(), TranscriptSource::None);
let artifacts = find("opencode").expect("registered").plugin_artifacts();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].file_name(), "tapes-gateway.ts");
}
#[test]
fn a_resolved_name_reaches_the_artifacts_an_installer_writes() {
let harness = find("pi").expect("pi is registered");
let artifacts = harness.plugin_artifacts();
assert_eq!(artifacts.len(), 1, "pi ships exactly one artifact");
assert_eq!(artifacts[0].file_name(), "tapes-gateway.ts");
assert!(
find("claude")
.expect("registered")
.plugin_artifacts()
.is_empty()
);
}
#[test]
fn declared_transcript_trees_resolve_to_a_path() {
for harness in REGISTRY {
match harness.transcripts() {
TranscriptSource::None => {
assert!(harness.transcript_root().is_none(), "{}", harness.id());
}
_ => assert!(
harness.transcript_root().is_some(),
"{} declares a transcript tree it cannot locate",
harness.id(),
),
}
}
}
#[test]
fn claude_transcripts_resolve_under_the_home_directory() {
let root = CLAUDE.transcript_root().expect("home dir");
assert!(root.ends_with(".claude/projects"), "got {}", root.display());
}
}