use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
use crate::install::io;
use crate::install::target::MergeOutcome;
const PLUGIN_ID: &str = "pixtuoid";
#[allow(dead_code)]
const SENTINEL: &str = "@pixtuoid-openclaw-plugin";
const HOOK_PLACEHOLDER: &str = "{{HOOK_PATH_JSON}}";
const PLUGIN_TEMPLATE: &str = include_str!("openclaw_plugin.js");
pub const OPENCLAW_EVENTS: &[&str] = &[
"gateway_start",
"gateway_stop",
"session_start",
"session_end",
"before_agent_run",
"agent_end",
];
const MANIFEST: &str = r#"{
"id": "pixtuoid",
"name": "Pixtuoid",
"description": "Forwards OpenClaw gateway daemon-presence signals to pixtuoid (the terminal office visualizer).",
"configSchema": { "type": "object", "additionalProperties": false, "properties": {} },
"activation": { "onStartup": true }
}
"#;
const PACKAGE: &str = r#"{
"name": "pixtuoid",
"version": "0.0.0",
"type": "module",
"private": true,
"openclaw": { "extensions": ["./index.js"], "runtimeExtensions": ["./index.js"] }
}
"#;
fn openclaw_home() -> Result<PathBuf> {
if let Some(d) = io::nonempty_env("OPENCLAW_STATE_DIR") {
return Ok(PathBuf::from(d));
}
io::home_relative_checked(".openclaw")
}
pub fn default_config_path() -> Result<PathBuf> {
Ok(openclaw_home()?.join("openclaw.json"))
}
fn plugin_dir() -> Result<PathBuf> {
Ok(openclaw_home()?.join("plugins").join(PLUGIN_ID))
}
pub fn detect_installed() -> bool {
openclaw_home().map(|d| d.exists()).unwrap_or(false)
}
pub fn hook_command(resolved: &Path, _explicit: bool) -> Result<String> {
resolved
.to_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("pixtuoid-hook path is non-UTF-8: {}", resolved.display()))
}
pub fn plugin_artifacts(hook_path: &Path) -> Result<Vec<(PathBuf, String)>> {
let dir = plugin_dir()?;
let hook = hook_path
.to_str()
.ok_or_else(|| anyhow!("pixtuoid-hook path is non-UTF-8: {}", hook_path.display()))?;
Ok(vec![
(dir.join("openclaw.plugin.json"), MANIFEST.to_string()),
(dir.join("package.json"), PACKAGE.to_string()),
(dir.join("index.js"), render_plugin(hook)?),
])
}
fn render_plugin(hook_path: &str) -> Result<String> {
let json = serde_json::to_string(hook_path)
.context("serializing the hook path into the openclaw plugin")?;
Ok(PLUGIN_TEMPLATE.replace(HOOK_PLACEHOLDER, &json))
}
fn parse_or_empty(content: &str) -> Result<Value> {
if content.trim().is_empty() {
Ok(json!({}))
} else {
serde_json::from_str(content).context("parsing openclaw.json")
}
}
fn obj_mut<'a>(v: &'a mut Value, key: &str) -> Result<&'a mut serde_json::Map<String, Value>> {
let map = v
.as_object_mut()
.ok_or_else(|| anyhow!("openclaw.json: `{key}` is not a JSON object"))?;
Ok(map)
}
pub fn merge_install(content: &str, _hook_cmd: &str) -> Result<MergeOutcome> {
let dir = plugin_dir()?;
let dir_str = dir
.to_str()
.ok_or_else(|| anyhow!("plugin dir path is non-UTF-8: {}", dir.display()))?
.to_string();
let mut root = parse_or_empty(content)?;
let before = root.clone();
{
let root_obj = obj_mut(&mut root, "root")?;
let plugins = root_obj.entry("plugins").or_insert_with(|| json!({}));
let plugins = obj_mut(plugins, "plugins")?;
let load = plugins.entry("load").or_insert_with(|| json!({}));
let load = obj_mut(load, "plugins.load")?;
let paths = load.entry("paths").or_insert_with(|| json!([]));
let paths = paths
.as_array_mut()
.ok_or_else(|| anyhow!("openclaw.json: `plugins.load.paths` is not an array"))?;
if !paths.iter().any(|p| p.as_str() == Some(dir_str.as_str())) {
paths.push(json!(dir_str));
}
let entries = plugins.entry("entries").or_insert_with(|| json!({}));
let entries = obj_mut(entries, "plugins.entries")?;
entries.insert(
PLUGIN_ID.to_string(),
json!({ "enabled": true, "hooks": { "allowConversationAccess": true } }),
);
}
let changed = root != before;
Ok(MergeOutcome {
changed,
content: serde_json::to_string_pretty(&root)? + "\n",
})
}
pub fn merge_uninstall(content: &str) -> Result<MergeOutcome> {
let dir = plugin_dir()?;
let dir_str = dir.to_str().map(str::to_string);
let mut root = parse_or_empty(content)?;
let before = root.clone();
if let Some(plugins) = root.get_mut("plugins").and_then(Value::as_object_mut) {
if let Some(paths) = plugins
.get_mut("load")
.and_then(Value::as_object_mut)
.and_then(|l| l.get_mut("paths"))
.and_then(Value::as_array_mut)
{
paths.retain(|p| p.as_str().map(str::to_string) != dir_str);
}
if let Some(entries) = plugins.get_mut("entries").and_then(Value::as_object_mut) {
entries.remove(PLUGIN_ID);
}
}
let changed = root != before;
Ok(MergeOutcome {
changed,
content: serde_json::to_string_pretty(&root)? + "\n",
})
}
pub fn verify_schema(content: &str) -> crate::install::verify::SchemaParse {
use crate::install::verify::{SchemaParse, ShimRef};
let Ok(root) = serde_json::from_str::<Value>(content) else {
return SchemaParse::broken("openclaw.json is not valid JSON — reconnect openclaw");
};
let entry = &root["plugins"]["entries"][PLUGIN_ID];
if entry.is_null() {
return SchemaParse::broken(
"the pixtuoid plugin entry is missing from openclaw.json — reconnect openclaw",
);
}
let mut issues = Vec::new();
if entry["enabled"] != json!(true) {
issues.push("the pixtuoid openclaw plugin is installed but disabled".into());
}
let registered = root["plugins"]["load"]["paths"]
.as_array()
.is_some_and(|paths| {
paths.iter().any(|p| {
p.as_str().is_some_and(|s| {
s.replace('\\', "/")
.ends_with(&format!("plugins/{PLUGIN_ID}"))
})
})
});
if !registered {
issues
.push("openclaw.json `load.paths` no longer registers the pixtuoid plugin dir".into());
}
SchemaParse {
issues,
shim: ShimRef::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openclaw_events_plugin_decoder_and_const_agree() {
use pixtuoid_core::source::openclaw::decode_openclaw_hook_payload;
for ev in OPENCLAW_EVENTS {
assert!(
PLUGIN_TEMPLATE.contains(&format!("\"{ev}\"")),
"plugin HOOKS is missing the registered event `{ev}`"
);
}
let hooks_block = PLUGIN_TEMPLATE
.split_once("const HOOKS = [")
.and_then(|(_, rest)| rest.split_once("];"))
.map(|(inner, _)| inner)
.expect("plugin defines a HOOKS array");
let registered: std::collections::HashSet<&str> = hooks_block
.split(',')
.map(|s| s.trim().trim_matches('"'))
.filter(|s| !s.is_empty())
.collect();
let expected: std::collections::HashSet<&str> = OPENCLAW_EVENTS.iter().copied().collect();
assert_eq!(
registered, expected,
"plugin HOOKS drifted from OPENCLAW_EVENTS"
);
for ev in OPENCLAW_EVENTS {
let payload = json!({ "type": ev });
let updates = decode_openclaw_hook_payload(&payload).unwrap();
assert!(
!updates.is_empty(),
"decode_openclaw_hook_payload has no arm for registered event `{ev}`"
);
}
}
#[test]
fn install_renders_plugin_with_baked_shim_path_and_sentinel() {
let arts = plugin_artifacts(Path::new("/opt/bin/pixtuoid-hook")).unwrap();
assert_eq!(arts.len(), 3, "manifest + package.json + index.js");
let index = &arts
.iter()
.find(|(p, _)| p.ends_with("index.js"))
.unwrap()
.1;
assert!(
index.contains(SENTINEL),
"entry module carries the sentinel"
);
assert!(
index.contains("\"/opt/bin/pixtuoid-hook\""),
"shim path baked JSON-escaped"
);
assert!(!index.contains(HOOK_PLACEHOLDER), "placeholder replaced");
assert!(
index.contains("--source"),
"spawns the shim with --source openclaw"
);
}
#[test]
fn merge_install_adds_load_path_enabled_and_the_grant() {
let out = merge_install("{}", "/opt/bin/pixtuoid-hook").unwrap();
assert!(out.changed);
let v: Value = serde_json::from_str(&out.content).unwrap();
let entry = &v["plugins"]["entries"]["pixtuoid"];
assert_eq!(entry["enabled"], json!(true));
assert_eq!(
entry["hooks"]["allowConversationAccess"],
json!(true),
"the busy-tell grant"
);
let paths = v["plugins"]["load"]["paths"].as_array().unwrap();
assert!(
paths.iter().any(|p| {
p.as_str()
.unwrap()
.replace('\\', "/")
.ends_with("plugins/pixtuoid")
}),
"load.paths points at the plugin dir"
);
}
#[test]
fn merge_install_is_idempotent() {
let a = merge_install("{}", "/x").unwrap();
let b = merge_install(&a.content, "/x").unwrap();
assert!(!b.changed, "re-install of the same state is a no-op");
}
#[test]
fn merge_install_preserves_foreign_config() {
let foreign = r#"{"gateway":{"mode":"local"},"plugins":{"entries":{"anthropic":{"enabled":true}},"load":{"paths":["/some/other/plugin"]}}}"#;
let out = merge_install(foreign, "/x").unwrap();
let v: Value = serde_json::from_str(&out.content).unwrap();
assert_eq!(v["gateway"]["mode"], json!("local"), "foreign keys survive");
assert_eq!(v["plugins"]["entries"]["anthropic"]["enabled"], json!(true));
let paths = v["plugins"]["load"]["paths"].as_array().unwrap();
assert!(
paths
.iter()
.any(|p| p.as_str() == Some("/some/other/plugin")),
"foreign path kept"
);
assert_eq!(paths.len(), 2, "ours appended, foreign kept");
}
#[test]
fn uninstall_revokes_the_grant_but_keeps_foreign_entries() {
let installed = merge_install(
r#"{"plugins":{"entries":{"anthropic":{"enabled":true}}}}"#,
"/x",
)
.unwrap();
let removed = merge_uninstall(&installed.content).unwrap();
assert!(removed.changed);
let v: Value = serde_json::from_str(&removed.content).unwrap();
assert!(
v["plugins"]["entries"].get("pixtuoid").is_none(),
"our entry (incl. the conversation-access grant) is revoked"
);
assert_eq!(
v["plugins"]["entries"]["anthropic"]["enabled"],
json!(true),
"a foreign plugin's grant survives"
);
let paths = v["plugins"]["load"]["paths"].as_array().unwrap();
assert!(
!paths
.iter()
.any(|p| p.as_str().unwrap().ends_with("plugins/pixtuoid")),
"our load.path removed"
);
}
#[test]
fn uninstall_of_unmanaged_config_is_a_no_op() {
assert!(!merge_uninstall("{}").unwrap().changed);
assert!(!merge_uninstall("").unwrap().changed);
assert!(
!merge_uninstall(r#"{"gateway":{"mode":"local"}}"#)
.unwrap()
.changed
);
}
#[test]
fn install_then_uninstall_round_trips() {
let installed = merge_install("{}", "/x").unwrap();
let removed = merge_uninstall(&installed.content).unwrap();
let v: Value = serde_json::from_str(&removed.content).unwrap();
assert!(v["plugins"]["entries"].get("pixtuoid").is_none());
}
#[test]
fn empty_content_is_treated_as_empty_document() {
let out = merge_install("", "/x").unwrap();
assert!(out.changed);
assert!(serde_json::from_str::<Value>(&out.content).is_ok());
}
#[test]
fn hook_command_returns_absolute_path() {
assert_eq!(
hook_command(Path::new("/opt/bin/pixtuoid-hook"), false).unwrap(),
"/opt/bin/pixtuoid-hook"
);
}
}