ctrlrelay 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Path encoding and template resolution for personalization sync.
|
|
2
|
+
|
|
3
|
+
Two responsibilities:
|
|
4
|
+
|
|
5
|
+
1. ``encode_project_path`` — replicate Claude Code's directory-name
|
|
6
|
+
encoding for ``~/.claude/projects/<encoded>/``. Verified empirically
|
|
7
|
+
against ~/.claude/projects/ on a real install: every character that
|
|
8
|
+
is not ``[A-Za-z0-9-]`` is replaced by ``-``. No collapsing of runs;
|
|
9
|
+
``/.ctrlrelay/`` becomes ``--ctrlrelay-`` (the slash + dot each
|
|
10
|
+
produce their own ``-``).
|
|
11
|
+
|
|
12
|
+
2. ``resolve_template`` — substitute the placeholder set declared in
|
|
13
|
+
``config.py`` (``${HOME}``, ``${PROJECT}``, ``${PROJECT_ENCODED}``,
|
|
14
|
+
``${PROJECT_LOCAL}``, ``${PROJECT_PARENT}``) in source/target paths
|
|
15
|
+
from ``Personalization.paths``.
|
|
16
|
+
|
|
17
|
+
Kept dependency-free so the manager can use it during init/wire without
|
|
18
|
+
loading the rest of the package.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Anything outside [A-Za-z0-9-] becomes '-'. Hyphens already in the
|
|
28
|
+
# original path stay as hyphens (idempotent under this rule).
|
|
29
|
+
_NON_SAFE_CHAR_RE = re.compile(r"[^A-Za-z0-9-]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def encode_project_path(absolute_path: Path | str, *, resolve_symlinks: bool = False) -> str:
|
|
33
|
+
"""Encode an absolute filesystem path the way Claude Code does for
|
|
34
|
+
its per-project directory under ``~/.claude/projects/``.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
absolute_path: Absolute path or ``~``-prefixed path. Relative
|
|
38
|
+
paths are an error.
|
|
39
|
+
resolve_symlinks: When True, follow symlinks before encoding.
|
|
40
|
+
Default False — match the path as configured in
|
|
41
|
+
``orchestrator.yaml`` (``RepoConfig.local_path``) which is
|
|
42
|
+
``expanduser``'d but not resolved. Set True only if you
|
|
43
|
+
observe Claude using the resolved form on this machine.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The encoded directory name (no leading slash; first character
|
|
47
|
+
is ``-`` because absolute paths start with ``/``).
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If ``absolute_path`` is not absolute after
|
|
51
|
+
``expanduser``.
|
|
52
|
+
"""
|
|
53
|
+
p = Path(absolute_path).expanduser()
|
|
54
|
+
if not p.is_absolute():
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"encode_project_path requires an absolute path, got {absolute_path!r}"
|
|
57
|
+
)
|
|
58
|
+
if resolve_symlinks:
|
|
59
|
+
p = p.resolve()
|
|
60
|
+
return _NON_SAFE_CHAR_RE.sub("-", str(p))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class TemplateContext:
|
|
65
|
+
"""Inputs for ``resolve_template``.
|
|
66
|
+
|
|
67
|
+
For non-project-scoped sync entries leave both fields ``None``.
|
|
68
|
+
For project-scoped entries set both: ``project`` is the
|
|
69
|
+
``<owner>-<repo>`` flat slug (avoids nested-owner-dir surprises and
|
|
70
|
+
name collisions across orgs); ``project_local`` is the
|
|
71
|
+
``RepoConfig.local_path`` value (already ``expanduser``'d at config
|
|
72
|
+
load time).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
project: str | None = None
|
|
76
|
+
project_local: Path | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_template(template: str, ctx: TemplateContext) -> Path:
|
|
80
|
+
"""Substitute placeholders in a source/target template and return a Path.
|
|
81
|
+
|
|
82
|
+
Recognized placeholders:
|
|
83
|
+
- ``${HOME}`` — current user's home directory.
|
|
84
|
+
- ``${PROJECT}`` — ``<owner>-<repo>`` flat slug.
|
|
85
|
+
- ``${PROJECT_ENCODED}`` — Claude's path encoding of
|
|
86
|
+
``project_local`` (cf. ``encode_project_path``).
|
|
87
|
+
- ``${PROJECT_LOCAL}`` — absolute path to the project working tree.
|
|
88
|
+
- ``${PROJECT_PARENT}`` — parent directory of ``project_local``.
|
|
89
|
+
|
|
90
|
+
Project placeholders raise ``ValueError`` when the context lacks
|
|
91
|
+
project information; ``HOME`` always works. Trailing slashes on the
|
|
92
|
+
template are preserved on the returned ``Path`` only by virtue of
|
|
93
|
+
the caller — ``Path`` itself does not retain trailing slashes —
|
|
94
|
+
so callers that care about file-vs-directory distinction must
|
|
95
|
+
inspect the *template string* (e.g., ``template.endswith("/")``)
|
|
96
|
+
not the returned path.
|
|
97
|
+
"""
|
|
98
|
+
result = template
|
|
99
|
+
if "${HOME}" in result:
|
|
100
|
+
result = result.replace("${HOME}", str(Path.home()))
|
|
101
|
+
|
|
102
|
+
needs_project = any(
|
|
103
|
+
marker in result
|
|
104
|
+
for marker in (
|
|
105
|
+
"${PROJECT}",
|
|
106
|
+
"${PROJECT_ENCODED}",
|
|
107
|
+
"${PROJECT_LOCAL}",
|
|
108
|
+
"${PROJECT_PARENT}",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
if needs_project:
|
|
112
|
+
if ctx.project is None or ctx.project_local is None:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"template {template!r} uses project placeholders but no "
|
|
115
|
+
"project context was provided"
|
|
116
|
+
)
|
|
117
|
+
# Replace longest matches first so ``${PROJECT_ENCODED}`` doesn't
|
|
118
|
+
# get partially-eaten by a ``${PROJECT}`` substitution.
|
|
119
|
+
result = result.replace("${PROJECT_ENCODED}", encode_project_path(ctx.project_local))
|
|
120
|
+
result = result.replace("${PROJECT_LOCAL}", str(ctx.project_local))
|
|
121
|
+
result = result.replace("${PROJECT_PARENT}", str(ctx.project_local.parent))
|
|
122
|
+
result = result.replace("${PROJECT}", ctx.project)
|
|
123
|
+
|
|
124
|
+
return Path(result).expanduser()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_PROJECT_SLUG_SEP = "--"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def project_slug(repo_name: str) -> str:
|
|
131
|
+
"""Flatten an ``owner/repo`` string to ``owner--repo``.
|
|
132
|
+
|
|
133
|
+
Used both for the ``${PROJECT}`` placeholder value and for the
|
|
134
|
+
on-disk source-tree directory name inside the personalization
|
|
135
|
+
repo (e.g. ``claude-memory/AInvirion--ctrlrelay/``). Avoids
|
|
136
|
+
nested owner subdirectories and, importantly, avoids collisions
|
|
137
|
+
where ``a-b/c`` and ``a/b-c`` would both flatten to ``a-b-c``
|
|
138
|
+
under a single-hyphen scheme. Double-hyphen is collision-free
|
|
139
|
+
for valid GitHub owner/repo pairs because GitHub disallows
|
|
140
|
+
consecutive hyphens in owner names (Codex review pass 6 caught
|
|
141
|
+
this).
|
|
142
|
+
"""
|
|
143
|
+
return repo_name.replace("/", _PROJECT_SLUG_SEP)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Pipeline implementations for ctrlrelay."""
|
|
2
|
+
|
|
3
|
+
from ctrlrelay.pipelines.base import Pipeline, PipelineContext, PipelineResult
|
|
4
|
+
from ctrlrelay.pipelines.dev import DevPipeline, run_dev_issue
|
|
5
|
+
from ctrlrelay.pipelines.secops import SecopsPipeline, run_secops_all
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Pipeline",
|
|
9
|
+
"PipelineContext",
|
|
10
|
+
"PipelineResult",
|
|
11
|
+
"SecopsPipeline",
|
|
12
|
+
"run_secops_all",
|
|
13
|
+
"DevPipeline",
|
|
14
|
+
"run_dev_issue",
|
|
15
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Base protocol and types for pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PipelineContext:
|
|
12
|
+
"""Context for a pipeline execution."""
|
|
13
|
+
|
|
14
|
+
session_id: str
|
|
15
|
+
repo: str
|
|
16
|
+
worktree_path: Path
|
|
17
|
+
context_path: Path
|
|
18
|
+
state_file: Path
|
|
19
|
+
issue_number: int | None = None
|
|
20
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PipelineResult:
|
|
25
|
+
"""Result of a pipeline execution."""
|
|
26
|
+
|
|
27
|
+
success: bool
|
|
28
|
+
session_id: str
|
|
29
|
+
summary: str
|
|
30
|
+
blocked: bool = False
|
|
31
|
+
question: str | None = None
|
|
32
|
+
error: str | None = None
|
|
33
|
+
outputs: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class Pipeline(Protocol):
|
|
38
|
+
"""Protocol for pipeline implementations."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
|
|
42
|
+
async def run(self, ctx: PipelineContext) -> PipelineResult:
|
|
43
|
+
"""Execute the pipeline."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
async def resume(
|
|
47
|
+
self, ctx: PipelineContext, answer: str
|
|
48
|
+
) -> PipelineResult:
|
|
49
|
+
"""Resume a blocked pipeline with user answer."""
|
|
50
|
+
...
|