feat: sync Python Factory Droid hook integration

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 11:22:42 -04:00
1 parent 865248a767
commit b92c9bea68
11 files changed
+2041 -2

No files matched your search

+2
View File
@@ -24,6 +24,7 @@ from .plugins.codex import CodexPlugin
from .plugins.cline import ClinePlugin
from .plugins.copilot import CopilotPlugin
from .plugins.cursor import CursorPlugin
from .plugins.droid import DroidPlugin
from .plugins.gemini import GeminiPlugin
from .plugins.grok import GrokPlugin
from .plugins.kirocli import KiroCliPlugin
@@ -40,6 +41,7 @@ PLUGIN_MAP: Dict[str, Type[AgentPlugin]] = {
"cline": ClinePlugin,
"copilot": CopilotPlugin,
"cursor": CursorPlugin,
"droid": DroidPlugin,
"gemini": GeminiPlugin,
"grok": GrokPlugin,
"kirocli": KiroCliPlugin,
+190
View File
@@ -0,0 +1,190 @@
"""Factory Droid native global user-hook integration."""
from __future__ import annotations
import json
import os
from typing import List, Optional
from .base import AgentPlugin, InstallConfig, PluginStatus
from .droid_config import (
additions_for,
installation_targets,
render_document,
unique_documents,
)
from .droid_contract import (
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
TOOL_EVENTS,
build_group,
elydora_dir,
merge_hook_settings,
runtime_contracts,
validate_javascript_regexes,
)
from .droid_io import (
FileChange,
display_config_path,
file_change,
read_sources,
rendered_change,
require_runtime,
runtime_files_exist,
write_changes,
)
from .hook_template import generate_hook_script
def _agent_directory(agent_id: str) -> str:
if (
not agent_id
or agent_id in {".", ".."}
or os.path.basename(agent_id) != agent_id
or os.path.isabs(agent_id)
):
raise ValueError("agent_id must be a single non-empty path segment")
return os.path.join(elydora_dir(), agent_id)
def _same_path(left: str, right: str) -> bool:
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
os.path.abspath(right)
)
def _append_change(changes: List[FileChange], change: Optional[FileChange]) -> None:
if change is not None:
changes.append(change)
class DroidPlugin(AgentPlugin):
"""Install Elydora into Factory Droid's user-level hook sources."""
def install(self, config: InstallConfig) -> None:
agent_id = config.get("agent_id", "")
agent_directory = _agent_directory(agent_id)
sources = read_sources()
validate_javascript_regexes([
sources.primary.hooks if sources.primary is not None else {},
sources.settings.hooks,
])
guard_path = config.get("guard_script_path", "")
expected_guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
if not _same_path(guard_path, expected_guard_path):
raise ValueError(
"Elydora guard runtime must use the managed agent directory: "
f"{expected_guard_path}"
)
require_runtime(guard_path, "Elydora guard runtime")
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
selected = installation_targets(sources)
groups = {
"PreToolUse": build_group(guard_path),
"PostToolUse": build_group(audit_path),
}
documents = unique_documents([
sources.primary,
sources.settings if sources.settings.has_hooks_container else None,
*(selected.targets[event] for event in TOOL_EVENTS),
])
rendered = [
render_document(
document,
None,
additions_for(document, selected.targets, groups),
)
for document in documents
]
runtime_config = {
"org_id": config.get("org_id", ""),
"agent_id": agent_id,
"kid": config.get("kid", ""),
"base_url": config.get("base_url", "https://api.elydora.com"),
"token": config.get("token", ""),
"agent_name": AGENT_KEY,
}
audit_script = generate_hook_script(
org_id=config.get("org_id", ""),
agent_id=agent_id,
private_key=config.get("private_key", ""),
kid=config.get("kid", ""),
base_url=config.get("base_url", "https://api.elydora.com"),
)
changes: List[FileChange] = []
_append_change(changes, file_change(
os.path.join(agent_directory, "config.json"),
"Elydora runtime config",
json.dumps(runtime_config, indent=2) + "\n",
0o600,
))
_append_change(changes, file_change(
os.path.join(agent_directory, "private.key"),
"Elydora private key",
config.get("private_key", ""),
0o600,
))
_append_change(changes, file_change(
audit_path,
"Elydora audit runtime",
audit_script,
0o700,
))
for item in rendered:
_append_change(changes, rendered_change(item))
write_changes(changes, "Write Factory Droid installation")
locations = ", ".join(
f"{event}: {selected.targets[event].file_path}"
for event in TOOL_EVENTS
)
print(f"Factory Droid: {locations}")
print("Factory Droid: run /hooks to review the Elydora hook changes.")
def uninstall(self, agent_id: str = "") -> None:
sources = read_sources()
documents = unique_documents([
sources.primary,
sources.settings if sources.settings.has_hooks_container else None,
])
changes: List[FileChange] = []
for document in documents:
rendered = render_document(document, agent_id or None, {})
_append_change(changes, rendered_change(rendered))
write_changes(changes, "Write Factory Droid hook sources")
def status(self) -> PluginStatus:
sources = read_sources()
effective = merge_hook_settings(
sources.primary.hooks if sources.primary is not None else None,
sources.settings.hooks,
)
contracts = runtime_contracts(effective)
config_path = display_config_path(sources)
if effective.get("hooksDisabled") is True:
return PluginStatus(
installed=False,
agent=AGENT_KEY,
details=f"Configured hooks are disabled: {config_path}",
)
if not contracts:
return PluginStatus(
installed=False,
agent=AGENT_KEY,
details="Not installed",
)
installed = runtime_files_exist(contracts)
details = (
f"Config: {config_path}"
if installed
else f"Configured at {config_path}; runtime scripts missing"
)
return PluginStatus(
installed=installed,
agent=AGENT_KEY,
details=details,
)
+262
View File
@@ -0,0 +1,262 @@
"""Factory Droid source selection and source-preserving rendering."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
from .droid_contract import (
TOOL_EVENTS,
DroidHookSettings,
JsonObject,
has_own,
managed_removals,
read_hook_settings,
)
from .droid_jsonc import JsonPathPart, JsoncEditor
OWNED_FILE_MARKER = "// Managed by Elydora"
@dataclass(frozen=True)
class DroidDocument:
kind: str
file_path: str
exists: bool
raw: str
root: JsonObject
hooks: DroidHookSettings
base_path: Tuple[JsonPathPart, ...]
has_hooks_container: bool
owned_file: bool
@dataclass(frozen=True)
class DroidSources:
root_path: str
primary: Optional[DroidDocument]
settings: DroidDocument
@dataclass(frozen=True)
class InstallationTargets:
targets: Dict[str, DroidDocument]
created_root: Optional[DroidDocument]
@dataclass(frozen=True)
class RenderedDocument:
document: DroidDocument
changed: bool
next_source: Optional[str]
def _label(kind: str, file_path: str) -> str:
if kind == "settings":
return f"Factory Droid settings at {file_path}"
if kind == "legacy":
return f"Factory Droid legacy hooks at {file_path}"
return f"Factory Droid hooks at {file_path}"
def parse_document(
*,
exists: bool,
file_path: str,
kind: str,
raw: str,
) -> DroidDocument:
label = _label(kind, file_path)
editor = JsoncEditor(raw, label)
if not isinstance(editor.value, dict):
raise ValueError(f"{label} must contain a JSON object")
root = editor.value
if kind == "settings":
has_container = has_own(root, "hooks")
hooks = (
read_hook_settings(root["hooks"], f'{label} field "hooks"')
if has_container
else {}
)
return DroidDocument(
kind,
file_path,
exists,
raw,
root,
hooks,
("hooks",),
has_container,
False,
)
return DroidDocument(
kind,
file_path,
exists,
raw,
root,
read_hook_settings(root, label),
(),
True,
raw.startswith(OWNED_FILE_MARKER),
)
def create_settings_document(file_path: str) -> DroidDocument:
return parse_document(
exists=False,
file_path=file_path,
kind="settings",
raw="{}\n",
)
def create_owned_hook_document(file_path: str) -> DroidDocument:
return parse_document(
exists=False,
file_path=file_path,
kind="hooks",
raw=f"{OWNED_FILE_MARKER}\n{{}}\n",
)
def _hooks_from_editor(
editor: JsoncEditor,
document: DroidDocument,
) -> DroidHookSettings:
root = editor.value
if not isinstance(root, dict):
raise ValueError(f"{_label(document.kind, document.file_path)} must be an object")
if document.kind == "settings":
hooks = root.get("hooks")
return read_hook_settings(
hooks,
f'{_label(document.kind, document.file_path)} field "hooks"',
)
return read_hook_settings(root, _label(document.kind, document.file_path))
def _event_path(document: DroidDocument, event: str) -> Tuple[JsonPathPart, ...]:
return (*document.base_path, event)
def _remove_managed(
editor: JsoncEditor,
document: DroidDocument,
agent_id: Optional[str],
) -> None:
removals = managed_removals(document.hooks, agent_id)
for event in TOOL_EVENTS:
event_removals = sorted(
(item for item in removals if item.event == event),
key=lambda item: item.group_index,
reverse=True,
)
for removal in event_removals:
group_path = (*_event_path(document, event), removal.group_index)
if removal.remove_group:
editor.delete(group_path)
continue
for handler_index in sorted(removal.handler_indexes, reverse=True):
editor.delete((*group_path, "hooks", handler_index))
current = _hooks_from_editor(editor, document)
if document.owned_file and event in current and not current[event]:
editor.delete(_event_path(document, event))
def render_document(
document: DroidDocument,
agent_id: Optional[str],
additions: Dict[str, JsonObject],
) -> RenderedDocument:
editor = JsoncEditor(
document.raw,
_label(document.kind, document.file_path),
)
_remove_managed(editor, document, agent_id)
for event in TOOL_EVENTS:
group = additions.get(event)
if group is None:
continue
current = _hooks_from_editor(editor, document)
if event in current:
editor.append(_event_path(document, event), group)
else:
editor.add_property(document.base_path, event, [group])
next_document = parse_document(
exists=document.exists,
file_path=document.file_path,
kind=document.kind,
raw=editor.raw,
)
if (
not additions
and document.owned_file
and document.kind != "settings"
and not next_document.hooks
):
return RenderedDocument(document, True, None)
return RenderedDocument(
document,
editor.raw != document.raw,
editor.raw,
)
def _event_target(
event: str,
sources: DroidSources,
create_root: "_RootFactory",
) -> DroidDocument:
if sources.primary is not None and event in sources.primary.hooks:
return sources.primary
if sources.settings.has_hooks_container and event in sources.settings.hooks:
return sources.settings
if sources.primary is not None:
return sources.primary
if sources.settings.has_hooks_container:
return sources.settings
return create_root.get()
class _RootFactory:
def __init__(self, file_path: str) -> None:
self.file_path = file_path
self.document: Optional[DroidDocument] = None
def get(self) -> DroidDocument:
if self.document is None:
self.document = create_owned_hook_document(self.file_path)
return self.document
def installation_targets(sources: DroidSources) -> InstallationTargets:
root = _RootFactory(sources.root_path)
targets = {
event: _event_target(event, sources, root)
for event in TOOL_EVENTS
}
return InstallationTargets(targets, root.document)
def additions_for(
document: DroidDocument,
targets: Dict[str, DroidDocument],
groups: Dict[str, JsonObject],
) -> Dict[str, JsonObject]:
return {
event: groups[event]
for event in TOOL_EVENTS
if targets[event].file_path == document.file_path
}
def unique_documents(
documents: Sequence[Optional[DroidDocument]],
) -> List[DroidDocument]:
unique: Dict[str, DroidDocument] = {}
for document in documents:
if document is not None:
unique[document.file_path] = document
return list(unique.values())
@@ -0,0 +1,373 @@
"""Factory Droid hook schema, command construction, and ownership rules."""
from __future__ import annotations
from dataclasses import dataclass
import json
import math
import os
import shutil
import subprocess # nosec B404
import sys
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
AGENT_KEY = "droid"
GUARD_SCRIPT = "guard.py"
AUDIT_SCRIPT = "hook.py"
HOOK_TIMEOUT_SECONDS = 10
TOOL_EVENTS = ("PreToolUse", "PostToolUse")
_EVENT_NAMES = {
"PreToolUse",
"PostToolUse",
"Notification",
"UserPromptSubmit",
"Stop",
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd",
}
_FLAG_NAMES = {"hooksDisabled", "showHookOutput"}
_HANDLER_KEYS = {"command", "timeout", "type"}
_GROUP_KEYS = {"hooks", "matcher"}
_REGEX_TIMEOUT_SECONDS = 10
_REGEX_VALIDATOR = """import fs from 'node:fs';
const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
for (const entry of entries) {
try {
new RegExp(entry.pattern);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(entry.label + ': ' + message);
process.exit(1);
}
}
"""
JsonObject = Dict[str, Any]
DroidHookSettings = Dict[str, Any]
@dataclass(frozen=True)
class RuntimeContract:
agent_id: str
guard_path: str
audit_path: str
@dataclass(frozen=True)
class ManagedRemoval:
event: str
group_index: int
handler_indexes: Tuple[int, ...]
remove_group: bool
def is_object(value: Any) -> bool:
return isinstance(value, dict)
def has_own(value: JsonObject, key: str) -> bool:
return key in value
def elydora_dir() -> str:
return os.path.join(os.path.expanduser("~"), ".elydora")
def _quote_windows(value: str) -> str:
return '"' + value.replace('"', '\\"') + '"'
def _quote_posix(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def build_command(script_path: str) -> str:
quote = _quote_windows if os.name == "nt" else _quote_posix
return f"{quote(sys.executable)} {quote(script_path)}"
def build_group(script_path: str) -> JsonObject:
return {
"matcher": "*",
"hooks": [{
"type": "command",
"command": build_command(script_path),
"timeout": HOOK_TIMEOUT_SECONDS,
}],
}
def _validate_handler(value: Any, label: str) -> JsonObject:
if not is_object(value):
raise ValueError(f"{label} must be an object")
handler = dict(value)
if handler.get("type") != "command":
raise ValueError(f'{label} type must be "command"')
if not isinstance(handler.get("command"), str):
raise ValueError(f"{label} command must be a string")
timeout = handler.get("timeout")
if timeout is not None and (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
):
raise ValueError(f"{label} timeout must be a finite number")
return handler
def _validate_group(value: Any, label: str) -> JsonObject:
if not is_object(value):
raise ValueError(f"{label} must be an object")
group = dict(value)
matcher = group.get("matcher")
if "matcher" in group and not isinstance(matcher, str):
raise ValueError(f"{label} matcher must be a string")
command_regex = group.get("commandRegex")
if "commandRegex" in group and not isinstance(command_regex, str):
raise ValueError(f"{label} commandRegex must be a string")
handlers = group.get("hooks")
if not isinstance(handlers, list):
raise ValueError(f"{label} must contain a hooks array")
group["hooks"] = [
_validate_handler(handler, f"{label}.hooks[{index}]")
for index, handler in enumerate(handlers)
]
return group
def read_hook_settings(value: Any, label: str) -> DroidHookSettings:
if not is_object(value):
raise ValueError(f"{label} must contain a JSON object")
settings: DroidHookSettings = dict(value)
for key, item in value.items():
if key in _FLAG_NAMES:
if not isinstance(item, bool):
raise ValueError(f'{label} field "{key}" must be a boolean')
continue
if key not in _EVENT_NAMES:
raise ValueError(f'{label} contains unsupported field "{key}"')
if not isinstance(item, list):
raise ValueError(f'{label} field "{key}" must be an array')
settings[key] = [
_validate_group(group, f'{label} field "{key}"[{index}]')
for index, group in enumerate(item)
]
return settings
def _regex_entries(settings: DroidHookSettings) -> List[JsonObject]:
entries: List[JsonObject] = []
for event, groups in settings.items():
if event in _FLAG_NAMES:
continue
for index, group in enumerate(groups):
matcher = group.get("matcher")
if isinstance(matcher, str) and matcher not in {"", "*"}:
entries.append({
"label": f'Factory Droid hooks field "{event}"[{index}] matcher',
"pattern": matcher,
})
command_regex = group.get("commandRegex")
if isinstance(command_regex, str):
entries.append({
"label": (
f'Factory Droid hooks field "{event}"[{index}] commandRegex'
),
"pattern": command_regex,
})
return entries
def validate_javascript_regexes(settings: Sequence[DroidHookSettings]) -> None:
entries = [entry for source in settings for entry in _regex_entries(source)]
if not entries:
return
node_path = shutil.which("node")
if node_path is None:
raise FileNotFoundError(
"Node.js runtime is required to validate Factory Droid hook expressions"
)
try:
result = subprocess.run( # nosec B603
[node_path, "--input-type=module", "--eval", _REGEX_VALIDATOR],
input=json.dumps(entries),
text=True,
capture_output=True,
check=False,
timeout=_REGEX_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired as error:
raise TimeoutError(
"Factory Droid hook expression validation timed out after "
f"{_REGEX_TIMEOUT_SECONDS} seconds"
) from error
except OSError as error:
raise OSError(f"Run Node.js Factory Droid matcher validator: {error}") from error
if result.returncode == 0:
return
message = (
result.stderr.strip()
or result.stdout.strip()
or f"Node.js exited with code {result.returncode}"
)
raise ValueError(
"Factory Droid matcher must be a valid JavaScript regular expression: "
f"{message}"
)
def _read_windows_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
if start >= len(command) or command[start] != '"':
return None
value = ""
index = start + 1
while index < len(command):
if command[index:index + 2] == '\\"':
value += '"'
index += 2
continue
if command[index] == '"':
return value, index + 1
value += command[index]
index += 1
return None
_POSIX_APOSTROPHE = "'\"'\"'"
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
if start >= len(command) or command[start] != "'":
return None
value = ""
index = start + 1
while index < len(command):
if command.startswith(_POSIX_APOSTROPHE, index):
value += "'"
index += len(_POSIX_APOSTROPHE)
continue
if command[index] == "'":
return value, index + 1
value += command[index]
index += 1
return None
def _parse_generated_command(command: str) -> Optional[Tuple[str, str]]:
reader = _read_windows_argument if os.name == "nt" else _read_posix_argument
executable = reader(command, 0)
if executable is None or executable[1] >= len(command):
return None
if command[executable[1]] != " ":
return None
script = reader(command, executable[1] + 1)
if script is None or script[1] != len(command):
return None
if not executable[0] or not script[0]:
return None
return executable[0], script[0]
def _same_path(left: str, right: str) -> bool:
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
os.path.abspath(right)
)
def same_agent_id(left: str, right: str) -> bool:
return os.path.normcase(left) == os.path.normcase(right)
def managed_agent_id(handler: JsonObject, script_name: str) -> Optional[str]:
if set(handler) != _HANDLER_KEYS:
return None
if handler.get("type") != "command" or handler.get("timeout") != 10:
return None
command = handler.get("command")
if not isinstance(command, str):
return None
parsed = _parse_generated_command(command)
if parsed is None or not _same_path(parsed[0], sys.executable):
return None
script_path = parsed[1]
if os.path.basename(script_path) != script_name:
return None
agent_directory = os.path.dirname(script_path)
if not _same_path(os.path.dirname(agent_directory), elydora_dir()):
return None
agent_id = os.path.basename(agent_directory)
return agent_id if agent_id not in {"", ".", ".."} else None
def _exact_owned_group(group: JsonObject, indexes: Tuple[int, ...]) -> bool:
hooks = group["hooks"]
return (
set(group) == _GROUP_KEYS
and group.get("matcher") == "*"
and bool(indexes)
and len(indexes) == len(hooks)
)
def managed_removals(
settings: DroidHookSettings,
agent_id: Optional[str] = None,
) -> List[ManagedRemoval]:
removals: List[ManagedRemoval] = []
for event, script_name in (
("PreToolUse", GUARD_SCRIPT),
("PostToolUse", AUDIT_SCRIPT),
):
for group_index, group in enumerate(settings.get(event, [])):
indexes = tuple(
index
for index, handler in enumerate(group["hooks"])
if (
(managed_id := managed_agent_id(handler, script_name)) is not None
and (agent_id is None or same_agent_id(managed_id, agent_id))
)
)
if indexes:
removals.append(ManagedRemoval(
event,
group_index,
indexes,
_exact_owned_group(group, indexes),
))
return removals
def _managed_ids(groups: List[JsonObject], script_name: str) -> Set[str]:
return {
agent_id
for group in groups
for handler in group["hooks"]
if (agent_id := managed_agent_id(handler, script_name)) is not None
}
def runtime_contracts(settings: DroidHookSettings) -> List[RuntimeContract]:
guards = _managed_ids(settings.get("PreToolUse", []), GUARD_SCRIPT)
audits = _managed_ids(settings.get("PostToolUse", []), AUDIT_SCRIPT)
contracts = []
for agent_id in sorted(guards):
if not any(same_agent_id(agent_id, audit_id) for audit_id in audits):
continue
root = os.path.join(elydora_dir(), agent_id)
contracts.append(RuntimeContract(
agent_id,
os.path.join(root, GUARD_SCRIPT),
os.path.join(root, AUDIT_SCRIPT),
))
return contracts
def merge_hook_settings(
primary: Optional[DroidHookSettings],
fallback: Optional[DroidHookSettings],
) -> DroidHookSettings:
return {**(fallback or {}), **(primary or {})}
+371
View File
@@ -0,0 +1,371 @@
"""Factory Droid source discovery and transactional multi-file I/O."""
from __future__ import annotations
from dataclasses import dataclass
import json
import os
import stat
import tempfile
from typing import List, Optional, Sequence
from uuid import uuid4
from .droid_config import (
DroidSources,
RenderedDocument,
create_settings_document,
parse_document,
)
from .droid_contract import AGENT_KEY, JsonObject, RuntimeContract, same_agent_id
@dataclass(frozen=True)
class FileChange:
file_path: str
label: str
original: Optional[str]
next_source: Optional[str]
mode: int
original_mode: Optional[int]
@dataclass
class StagedChange:
change: FileChange
temporary_path: Optional[str]
rollback_path: Optional[str]
committed: bool = False
def _factory_paths() -> tuple[str, str, str]:
directory = os.path.join(os.path.expanduser("~"), ".factory")
return (
os.path.join(directory, "hooks.json"),
os.path.join(directory, "hooks", "hooks.json"),
os.path.join(directory, "settings.json"),
)
def _read_optional(file_path: str, label: str) -> Optional[str]:
try:
with open(file_path, "r", encoding="utf-8", newline="") as file:
return file.read()
except FileNotFoundError:
return None
except OSError as error:
raise OSError(f"Read {label} at {file_path}: {error}") from error
def read_sources() -> DroidSources:
root_path, legacy_path, settings_path = _factory_paths()
root_raw = _read_optional(root_path, "Factory Droid hooks")
settings_raw = _read_optional(settings_path, "Factory Droid settings")
primary = None
if root_raw is not None:
primary = parse_document(
exists=True,
file_path=root_path,
kind="hooks",
raw=root_raw,
)
else:
legacy_raw = _read_optional(legacy_path, "Factory Droid legacy hooks")
if legacy_raw is not None:
primary = parse_document(
exists=True,
file_path=legacy_path,
kind="legacy",
raw=legacy_raw,
)
settings = (
create_settings_document(settings_path)
if settings_raw is None
else parse_document(
exists=True,
file_path=settings_path,
kind="settings",
raw=settings_raw,
)
)
return DroidSources(root_path, primary, settings)
def _existing_mode(file_path: str) -> Optional[int]:
try:
return stat.S_IMODE(os.stat(file_path).st_mode)
except FileNotFoundError:
return None
except OSError as error:
raise OSError(f"Read file mode at {file_path}: {error}") from error
def file_change(
file_path: str,
label: str,
next_source: Optional[str],
mode: int,
) -> Optional[FileChange]:
original = _read_optional(file_path, label)
if original == next_source:
return None
return FileChange(
file_path,
label,
original,
next_source,
mode,
_existing_mode(file_path),
)
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
if not rendered.changed:
return None
if rendered.document.kind == "settings":
label = "Factory Droid settings"
elif rendered.document.kind == "legacy":
label = "Factory Droid legacy hooks"
else:
label = "Factory Droid hooks"
return FileChange(
rendered.document.file_path,
label,
rendered.document.raw if rendered.document.exists else None,
rendered.next_source,
0o600,
_existing_mode(rendered.document.file_path),
)
def _remove_optional(file_path: Optional[str]) -> None:
if not file_path:
return
try:
os.remove(file_path)
except FileNotFoundError:
return
def _cleanup_paths(paths: Sequence[Optional[str]]) -> List[str]:
errors = []
for path in paths:
try:
_remove_optional(path)
except OSError as error:
errors.append(str(error))
return errors
def _write_staged(
directory: str,
basename: str,
suffix: str,
content: str,
mode: int,
label: str,
) -> str:
descriptor = -1
temporary_path = ""
try:
descriptor, temporary_path = tempfile.mkstemp(
prefix=f".{basename}.",
suffix=suffix,
dir=directory,
text=True,
)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file:
descriptor = -1
file.write(content)
file.flush()
os.fsync(file.fileno())
os.chmod(temporary_path, mode)
return temporary_path
except Exception as error:
failures = []
if descriptor >= 0:
try:
os.close(descriptor)
except OSError as close_error:
failures.append(f"close failed: {close_error}")
failures.extend(_cleanup_paths((temporary_path,)))
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
raise OSError(f"Stage {label}: {error}{suffix}") from error
def _assert_unchanged(change: FileChange) -> None:
current = _read_optional(change.file_path, change.label)
if current != change.original:
raise OSError(f"{change.label} changed during installation: {change.file_path}")
def _stage(change: FileChange) -> StagedChange:
_assert_unchanged(change)
directory = os.path.dirname(change.file_path)
try:
os.makedirs(directory, mode=0o700, exist_ok=True)
except OSError as error:
raise OSError(f"Create directory for {change.label} at {directory}: {error}") from error
basename = os.path.basename(change.file_path)
temporary_path = None
rollback_path = None
try:
if change.next_source is not None:
temporary_path = _write_staged(
directory,
basename,
".tmp",
change.next_source,
change.mode,
change.label,
)
if change.original is not None and change.next_source is not None:
rollback_path = _write_staged(
directory,
basename,
".rollback",
change.original,
change.original_mode or change.mode,
f"{change.label} rollback",
)
elif change.original is not None:
rollback_path = os.path.join(
directory,
f".{basename}.{uuid4().hex}.rollback",
)
if os.path.lexists(rollback_path):
raise FileExistsError(f"Rollback path already exists: {rollback_path}")
return StagedChange(change, temporary_path, rollback_path)
except Exception as error:
failures = _cleanup_paths((temporary_path, rollback_path))
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
raise OSError(f"Stage {change.label}: {error}{suffix}") from error
def _commit(staged: StagedChange) -> None:
_assert_unchanged(staged.change)
if staged.change.next_source is None:
if staged.rollback_path is None:
raise OSError(f"Missing rollback path for {staged.change.label}")
os.replace(staged.change.file_path, staged.rollback_path)
else:
if staged.temporary_path is None:
raise OSError(f"Missing staged file for {staged.change.label}")
os.replace(staged.temporary_path, staged.change.file_path)
staged.committed = True
def _rollback(staged: StagedChange) -> None:
if not staged.committed:
return
if staged.change.original is None:
_remove_optional(staged.change.file_path)
return
if staged.rollback_path is None:
raise OSError(f"Missing rollback data for {staged.change.label}")
os.replace(staged.rollback_path, staged.change.file_path)
def _cleanup(staged: StagedChange) -> None:
_remove_optional(staged.temporary_path)
_remove_optional(staged.rollback_path)
def write_changes(changes: Sequence[FileChange], label: str) -> None:
filtered = [change for change in changes if change.original != change.next_source]
if not filtered:
return
paths = [os.path.normcase(os.path.abspath(change.file_path)) for change in filtered]
if len(paths) != len(set(paths)):
raise ValueError(f"{label} contains duplicate file targets")
staged: List[StagedChange] = []
try:
for change in filtered:
staged.append(_stage(change))
for item in staged:
_commit(item)
except Exception as error:
recovery_errors = []
for item in reversed(staged):
try:
_rollback(item)
except OSError as rollback_error:
recovery_errors.append(str(rollback_error))
for item in staged:
try:
_cleanup(item)
except OSError as cleanup_error:
recovery_errors.append(str(cleanup_error))
suffix = (
f"; recovery failed: {'; '.join(recovery_errors)}"
if recovery_errors
else ""
)
raise OSError(f"{label}: {error}{suffix}") from error
cleanup_errors = []
for item in staged:
try:
_cleanup(item)
except OSError as error:
cleanup_errors.append(str(error))
if cleanup_errors:
raise OSError(f"{label} cleanup failed: {'; '.join(cleanup_errors)}")
def regular_file_exists(file_path: str, label: str) -> bool:
try:
return stat.S_ISREG(os.stat(file_path).st_mode)
except FileNotFoundError:
return False
except OSError as error:
raise OSError(f"Read {label} at {file_path}: {error}") from error
def require_runtime(file_path: str, label: str) -> None:
if not file_path:
raise ValueError(f"{label} path is required")
if not regular_file_exists(file_path, label):
raise FileNotFoundError(f"{label} is missing: {file_path}")
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
raw = _read_optional(file_path, "Elydora runtime config")
if raw is None:
return None
try:
value = json.loads(raw)
except json.JSONDecodeError as error:
raise ValueError(
f"Failed to parse Elydora runtime config at {file_path}: {error}"
) from error
if not isinstance(value, dict):
raise ValueError(
f"Elydora runtime config at {file_path} must contain a JSON object"
)
return value
def runtime_files_exist(contracts: Sequence[RuntimeContract]) -> bool:
for contract in contracts:
agent_directory = os.path.dirname(contract.guard_path)
config = _read_runtime_config(os.path.join(agent_directory, "config.json"))
config_agent_id = config.get("agent_id") if config else None
if (
config is None
or config.get("agent_name") != AGENT_KEY
or not isinstance(config_agent_id, str)
or not same_agent_id(config_agent_id, contract.agent_id)
):
continue
if regular_file_exists(
contract.guard_path, "Elydora guard runtime"
) and regular_file_exists(contract.audit_path, "Elydora audit runtime"):
return True
return False
def display_config_path(sources: DroidSources) -> str:
if sources.primary is not None:
return sources.primary.file_path
if sources.settings.exists and sources.settings.has_hooks_container:
return sources.settings.file_path
return sources.root_path
+387
View File
@@ -0,0 +1,387 @@
"""Strict JSONC parsing and source-preserving edits for Factory Droid."""
from __future__ import annotations
from dataclasses import dataclass
import json
import re
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
JsonObject = Dict[str, Any]
JsonPathPart = Union[str, int]
_NUMBER = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?")
@dataclass(frozen=True)
class JsoncItem:
node: "JsoncNode"
comma: Optional[int]
@dataclass(frozen=True)
class JsoncMember:
key: str
start: int
end: int
value_node: "JsoncNode"
comma: Optional[int]
@dataclass(frozen=True)
class JsoncNode:
kind: str
start: int
end: int
value: Any
members: Tuple[JsoncMember, ...] = ()
items: Tuple[JsoncItem, ...] = ()
class _Parser:
def __init__(self, raw: str, label: str) -> None:
self.raw = raw
self.label = label
self.position = 0
def _error(self, message: str, position: Optional[int] = None) -> ValueError:
offset = self.position if position is None else position
return ValueError(f"Failed to parse {self.label}: {message} at offset {offset}")
def _skip_trivia(self) -> None:
while self.position < len(self.raw):
if self.position == 0 and self.raw.startswith("\ufeff"):
self.position += 1
continue
character = self.raw[self.position]
if character in " \t\r\n":
self.position += 1
continue
if self.raw.startswith("//", self.position):
newline = self.raw.find("\n", self.position + 2)
self.position = len(self.raw) if newline < 0 else newline + 1
continue
if self.raw.startswith("/*", self.position):
end = self.raw.find("*/", self.position + 2)
if end < 0:
raise self._error("unterminated block comment")
self.position = end + 2
continue
return
def _expect(self, character: str) -> None:
if self.position >= len(self.raw) or self.raw[self.position] != character:
raise self._error(f'expected "{character}"')
self.position += 1
def _parse_string(self) -> Tuple[str, int, int]:
start = self.position
self._expect('"')
escaped = False
while self.position < len(self.raw):
character = self.raw[self.position]
self.position += 1
if escaped:
escaped = False
continue
if character == "\\":
escaped = True
continue
if character == '"':
token = self.raw[start:self.position]
try:
value = json.loads(token)
except json.JSONDecodeError as error:
raise self._error(error.msg, start + error.pos) from error
return value, start, self.position
if ord(character) < 0x20:
raise self._error("unescaped control character", self.position - 1)
raise self._error("unterminated string", start)
def _parse_object(self) -> JsoncNode:
start = self.position
self.position += 1
members: List[JsoncMember] = []
value: JsonObject = {}
seen = set()
self._skip_trivia()
if self.position < len(self.raw) and self.raw[self.position] == "}":
self.position += 1
return JsoncNode("object", start, self.position, value)
while True:
self._skip_trivia()
if self.position >= len(self.raw) or self.raw[self.position] != '"':
raise self._error("object key must be a string")
key, member_start, _ = self._parse_string()
if key in seen:
raise self._error(f'duplicate field "{key}"', member_start)
seen.add(key)
self._skip_trivia()
self._expect(":")
node = self._parse_value()
value[key] = node.value
member_end = node.end
self._skip_trivia()
comma: Optional[int] = None
if self.position < len(self.raw) and self.raw[self.position] == ",":
comma = self.position
self.position += 1
self._skip_trivia()
members.append(JsoncMember(
key, member_start, member_end, node, comma
))
if self.position < len(self.raw) and self.raw[self.position] == "}":
self.position += 1
return JsoncNode(
"object", start, self.position, value, tuple(members)
)
continue
members.append(JsoncMember(key, member_start, member_end, node, None))
if self.position >= len(self.raw) or self.raw[self.position] != "}":
raise self._error('expected "," or "}"')
self.position += 1
return JsoncNode("object", start, self.position, value, tuple(members))
def _parse_array(self) -> JsoncNode:
start = self.position
self.position += 1
items: List[JsoncItem] = []
values: List[Any] = []
self._skip_trivia()
if self.position < len(self.raw) and self.raw[self.position] == "]":
self.position += 1
return JsoncNode("array", start, self.position, values)
while True:
node = self._parse_value()
values.append(node.value)
self._skip_trivia()
comma: Optional[int] = None
if self.position < len(self.raw) and self.raw[self.position] == ",":
comma = self.position
self.position += 1
self._skip_trivia()
items.append(JsoncItem(node, comma))
if self.position < len(self.raw) and self.raw[self.position] == "]":
self.position += 1
return JsoncNode(
"array", start, self.position, values, items=tuple(items)
)
continue
items.append(JsoncItem(node, None))
if self.position >= len(self.raw) or self.raw[self.position] != "]":
raise self._error('expected "," or "]"')
self.position += 1
return JsoncNode(
"array", start, self.position, values, items=tuple(items)
)
def _parse_value(self) -> JsoncNode:
self._skip_trivia()
if self.position >= len(self.raw):
raise self._error("expected a value")
start = self.position
character = self.raw[self.position]
if character == "{":
return self._parse_object()
if character == "[":
return self._parse_array()
if character == '"':
value, _, end = self._parse_string()
return JsoncNode("string", start, end, value)
for token, literal in (("true", True), ("false", False), ("null", None)):
if self.raw.startswith(token, self.position):
self.position += len(token)
return JsoncNode("scalar", start, self.position, literal)
match = _NUMBER.match(self.raw, self.position)
if match:
self.position = match.end()
token = match.group(0)
number = float(token) if any(marker in token for marker in ".eE") else int(token)
return JsoncNode("number", start, self.position, number)
raise self._error("invalid value")
def parse(self) -> JsoncNode:
node = self._parse_value()
self._skip_trivia()
if self.position != len(self.raw):
raise self._error("unexpected content")
return node
def parse_jsonc(raw: str, label: str) -> Any:
"""Parse strict JSON plus comments and trailing commas."""
return _Parser(raw, label).parse().value
class JsoncEditor:
"""Apply narrow path edits while retaining untouched source bytes."""
def __init__(self, raw: str, label: str) -> None:
self.raw = raw
self.label = label
self._refresh()
@property
def value(self) -> Any:
return self.root.value
def _refresh(self) -> None:
self.root = _Parser(self.raw, self.label).parse()
def _resolve(self, path: Sequence[JsonPathPart]) -> JsoncNode:
node = self.root
for part in path:
if isinstance(part, str) and node.kind == "object":
member = next((item for item in node.members if item.key == part), None)
if member is None:
raise KeyError(f"Missing JSONC path: {list(path)}")
node = member.value_node
continue
if isinstance(part, int) and node.kind == "array":
if part < 0 or part >= len(node.items):
raise IndexError(f"Invalid JSONC path: {list(path)}")
node = node.items[part].node
continue
raise TypeError(f"Invalid JSONC path: {list(path)}")
return node
def _apply(self, ranges: Sequence[Tuple[int, int, str]]) -> None:
next_raw = self.raw
for start, end, replacement in sorted(ranges, reverse=True):
next_raw = next_raw[:start] + replacement + next_raw[end:]
self.raw = next_raw
self._refresh()
def delete(self, path: Sequence[JsonPathPart]) -> None:
if not path:
raise ValueError("Cannot delete the JSONC root")
parent = self._resolve(path[:-1])
part = path[-1]
ranges: List[Tuple[int, int, str]] = []
if isinstance(part, str) and parent.kind == "object":
index = next(
(position for position, item in enumerate(parent.members) if item.key == part),
-1,
)
if index < 0:
raise KeyError(f"Missing JSONC path: {list(path)}")
member = parent.members[index]
ranges.append((member.start, member.end, ""))
if member.comma is not None:
ranges.append((member.comma, member.comma + 1, ""))
elif index > 0 and parent.members[index - 1].comma is not None:
comma = parent.members[index - 1].comma
if comma is None:
raise RuntimeError("JSONC object separator metadata is missing")
ranges.append((comma, comma + 1, ""))
elif isinstance(part, int) and parent.kind == "array":
if part < 0 or part >= len(parent.items):
raise IndexError(f"Invalid JSONC path: {list(path)}")
item = parent.items[part]
ranges.append((item.node.start, item.node.end, ""))
if item.comma is not None:
ranges.append((item.comma, item.comma + 1, ""))
elif part > 0 and parent.items[part - 1].comma is not None:
comma = parent.items[part - 1].comma
if comma is None:
raise RuntimeError("JSONC array separator metadata is missing")
ranges.append((comma, comma + 1, ""))
else:
raise TypeError(f"Invalid JSONC path: {list(path)}")
self._apply(ranges)
def _eol(self) -> str:
return "\r\n" if "\r\n" in self.raw else "\n"
def _indent_unit(self) -> str:
match = re.search(r"\r?\n([ \t]+)\S", self.raw)
if match is None:
return " "
indentation = match.group(1)
return "\t" if "\t" in indentation else " " * len(indentation)
def _line_indent(self, position: int) -> str:
line_start = self.raw.rfind("\n", 0, position) + 1
prefix = self.raw[line_start:position]
match = re.match(r"[ \t]*", prefix)
if match is None:
raise RuntimeError("JSONC indentation parser failed")
return match.group(0)
def _json_lines(self, value: Any) -> List[str]:
unit = self._indent_unit()
lines = json.dumps(value, ensure_ascii=False, indent=1).splitlines()
converted = []
for line in lines:
depth = len(line) - len(line.lstrip(" "))
converted.append(unit * depth + line[depth:])
return converted
def _insert(
self,
parent: JsoncNode,
lines: List[str],
had_trailing_comma: bool,
) -> None:
close = parent.end - 1
eol = self._eol()
unit = self._indent_unit()
pretty = "\n" in self.raw or "\r" in self.raw
ranges: List[Tuple[int, int, str]] = []
has_existing = bool(parent.members if parent.kind == "object" else parent.items)
if has_existing and not had_trailing_comma:
if parent.kind == "object":
previous_end = parent.members[-1].end
else:
previous_end = parent.items[-1].node.end
ranges.append((previous_end, previous_end, ","))
if pretty:
close_line_start = self.raw.rfind("\n", 0, close) + 1
close_prefix = self.raw[close_line_start:close]
if close_line_start > parent.start and not close_prefix.strip():
insertion_at = close_line_start
parent_indent = close_prefix
prefix = ""
suffix = eol
else:
insertion_at = close
parent_indent = self._line_indent(parent.start)
prefix = eol
suffix = eol + parent_indent
child_indent = parent_indent + unit
content = eol.join(child_indent + line for line in lines)
if had_trailing_comma:
content += ","
insertion = prefix + content + suffix
else:
insertion_at = close
content = " ".join(lines)
if had_trailing_comma:
content += ","
insertion = (" " if has_existing else "") + content
ranges.append((insertion_at, insertion_at, insertion))
self._apply(ranges)
def add_property(
self,
path: Sequence[JsonPathPart],
key: str,
value: Any,
) -> None:
parent = self._resolve(path)
if parent.kind != "object":
raise TypeError(f"JSONC path must be an object: {list(path)}")
if any(member.key == key for member in parent.members):
raise KeyError(f'JSONC field "{key}" already exists')
value_lines = self._json_lines(value)
key_text = json.dumps(key, ensure_ascii=False) + ": "
lines = [key_text + value_lines[0], *value_lines[1:]]
trailing = bool(parent.members and parent.members[-1].comma is not None)
self._insert(parent, lines, trailing)
def append(self, path: Sequence[JsonPathPart], value: Any) -> None:
parent = self._resolve(path)
if parent.kind != "array":
raise TypeError(f"JSONC path must be an array: {list(path)}")
trailing = bool(parent.items and parent.items[-1].comma is not None)
self._insert(parent, self._json_lines(value), trailing)
+5
View File
@@ -36,6 +36,11 @@ SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = {
"hook_event": "PostToolUse",
"config_path": "~/.cursor/hooks.json",
},
"droid": {
"name": "Factory Droid",
"hook_event": "PreToolUse/PostToolUse",
"config_path": "~/.factory/hooks.json",
},
"gemini": {
"name": "Gemini CLI",
"hook_event": "AfterTool",