leos-agent 7.0.0 → 10.2.0
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.
- package/README.md +544 -28
- package/commands/handoff.md +11 -0
- package/commands/handon.md +10 -0
- package/commands/review-pr.md +9 -0
- package/commands-claude/watch-review.md +9 -0
- package/index.js +12 -0
- package/package.json +29 -14
- package/payload/codex-agents/leo-executor.toml +36 -0
- package/payload/codex-agents/leo-runner.toml +28 -0
- package/rules/preferences.md +97 -0
- package/scripts/check.py +244 -0
- package/scripts/ghreview.py +17 -3
- package/scripts/handoff.py +183 -0
- package/scripts/leo-install.py +509 -0
- package/scripts/measure_context.py +113 -0
- package/scripts/publish-npm.py +138 -0
- package/scripts/watch_review.py +169 -0
- package/skills/doctor/SKILL.md +73 -115
- package/skills/doctor/agents/openai.yaml +5 -0
- package/skills/handoff/SKILL.md +99 -0
- package/skills/handoff/agents/openai.yaml +5 -0
- package/skills/handon/SKILL.md +61 -0
- package/skills/install/SKILL.md +79 -0
- package/skills/install/agents/openai.yaml +5 -0
- package/skills/review-pr/SKILL.md +59 -318
- package/skills/review-pr/reference/lenses.md +67 -0
- package/skills/review-pr/reference/procedure.md +348 -0
- package/skills-claude/attach-pr/SKILL.md +178 -0
- package/skills-claude/watch-review/SKILL.md +91 -0
- package/adapters/cursor/agents/executor.md +0 -17
- package/adapters/cursor/agents/expert.md +0 -70
- package/adapters/cursor/agents/explore.md +0 -16
- package/adapters/cursor/agents/implementer.md +0 -18
- package/adapters/cursor/agents/investigator.md +0 -18
- package/adapters/cursor/agents/planner.md +0 -28
- package/adapters/cursor/agents/review-lens.md +0 -22
- package/adapters/cursor/agents/reviewer.md +0 -34
- package/adapters/opencode/agents.json +0 -105
- package/adapters/opencode/plugin.js +0 -576
- package/config/MCP_PINS.md +0 -17
- package/config/models.json +0 -676
- package/hooks/bash-guard.py +0 -583
- package/hooks/cursor-guard.py +0 -84
- package/hooks/hooks-cursor.json +0 -11
- package/hooks/hooks.json +0 -20
- package/hooks/session-start.py +0 -148
- package/roles/executor.md +0 -15
- package/roles/expert.md +0 -67
- package/roles/explore.md +0 -13
- package/roles/implementer.md +0 -16
- package/roles/investigator.md +0 -15
- package/roles/planner.md +0 -25
- package/roles/review-lens.md +0 -20
- package/roles/reviewer.md +0 -31
- package/scripts/doctor.py +0 -520
- package/scripts/jsonc_bridge.cjs +0 -23
- package/scripts/memory.py +0 -744
- package/scripts/render_adapters.py +0 -508
- package/scripts/setup.py +0 -1753
- package/settings.json +0 -7
- package/skills/.gitkeep +0 -0
- package/skills/brainstorming/SKILL.md +0 -111
- package/skills/debugging/SKILL.md +0 -100
- package/skills/delegation/SKILL.md +0 -143
- package/skills/executing-plans/SKILL.md +0 -117
- package/skills/finishing-a-branch/SKILL.md +0 -125
- package/skills/freshness/SKILL.md +0 -131
- package/skills/memory/SKILL.md +0 -154
- package/skills/resolve-ticket/SKILL.md +0 -275
- package/skills/setup/SKILL.md +0 -199
- package/skills/setup/agents/openai.yaml +0 -5
- package/skills/test-first/SKILL.md +0 -92
- package/skills/using-leo/SKILL.md +0 -101
- package/skills/using-leo/references/claude-mapping.md +0 -33
- package/skills/using-leo/references/codex-mapping.md +0 -33
- package/skills/using-leo/references/cursor-mapping.md +0 -35
- package/skills/using-leo/references/hermes-mapping.md +0 -37
- package/skills/using-leo/references/opencode-mapping.md +0 -39
- package/skills/verification/SKILL.md +0 -110
- package/skills/visual-verification/SKILL.md +0 -115
- package/skills/watch-review/SKILL.md +0 -128
- package/skills/watch-review/agents/openai.yaml +0 -5
- package/skills/worktrees/SKILL.md +0 -131
- package/skills/writing-plans/SKILL.md +0 -97
- package/skills/writing-skills/SKILL.md +0 -141
- package/vendor/jsonc-parser-3.3.1/LICENSE.md +0 -21
- package/vendor/jsonc-parser-3.3.1/README.md +0 -26
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +0 -201
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +0 -275
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +0 -682
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +0 -456
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +0 -42
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +0 -351
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +0 -194
- package/vendor/jsonc-parser-3.3.1/package.json +0 -37
- package/workflows/cost-tiered-fix.js +0 -287
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""handoff: machine-local session handoff documents for leos-agent.
|
|
3
|
+
|
|
4
|
+
A handoff is a markdown file one session writes and another session — in this
|
|
5
|
+
harness or a different one — reads to pick the work back up. This script owns
|
|
6
|
+
naming, paths, and listing only. It never composes prose: the model writes the
|
|
7
|
+
body, because summarising a session is the one part of this that needs a model.
|
|
8
|
+
|
|
9
|
+
DATA lives under the same root as state.py, ${LEOS_AGENT_LOCAL_PATH:-
|
|
10
|
+
$HOME/.leos-agent-local}, never derived from this file's own location — a
|
|
11
|
+
plugin update can wipe or relocate the code, and must never take handoffs with
|
|
12
|
+
it. Files land at <root>/handoffs/<name>.md.
|
|
13
|
+
|
|
14
|
+
handoff.py new <slug> print the de-collided name and its path
|
|
15
|
+
handoff.py path <name> resolve a name (exact, else unique prefix)
|
|
16
|
+
handoff.py list [--all] [--limit N] recent handoffs, newest first
|
|
17
|
+
handoff.py rm <name> delete one
|
|
18
|
+
|
|
19
|
+
Nothing is ever pruned automatically; `rm` is the only way a handoff goes away.
|
|
20
|
+
`list` shows only handoffs written in or under the current directory unless
|
|
21
|
+
--all is passed. Exit codes: 0 ok, non-zero on error.
|
|
22
|
+
"""
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
29
|
+
from state import _data_root # noqa: E402 same data root, deliberately shared
|
|
30
|
+
|
|
31
|
+
SLUG = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def handoff_dir():
|
|
35
|
+
path = os.path.join(_data_root(), "handoffs")
|
|
36
|
+
os.makedirs(path, exist_ok=True)
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def file_for(name):
|
|
41
|
+
return os.path.join(handoff_dir(), f"{name}.md")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def frontmatter(path):
|
|
45
|
+
"""Parse the leading --- block. Returns {} for a file without one."""
|
|
46
|
+
data = {}
|
|
47
|
+
try:
|
|
48
|
+
with open(path) as fh:
|
|
49
|
+
if fh.readline().strip() != "---":
|
|
50
|
+
return data
|
|
51
|
+
for line in fh:
|
|
52
|
+
if line.strip() == "---":
|
|
53
|
+
break
|
|
54
|
+
key, sep, value = line.partition(":")
|
|
55
|
+
if sep:
|
|
56
|
+
data[key.strip()] = value.strip()
|
|
57
|
+
except OSError:
|
|
58
|
+
return {}
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def title_of(path):
|
|
63
|
+
try:
|
|
64
|
+
with open(path) as fh:
|
|
65
|
+
seen_open = False
|
|
66
|
+
for line in fh:
|
|
67
|
+
if line.strip() == "---":
|
|
68
|
+
if seen_open:
|
|
69
|
+
break
|
|
70
|
+
seen_open = True
|
|
71
|
+
continue
|
|
72
|
+
for line in fh:
|
|
73
|
+
if line.startswith("# "):
|
|
74
|
+
return line[2:].strip()
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def age_of(created):
|
|
81
|
+
try:
|
|
82
|
+
stamp = dt.datetime.fromisoformat(created.replace("Z", "+00:00"))
|
|
83
|
+
except (ValueError, AttributeError):
|
|
84
|
+
return "?"
|
|
85
|
+
if stamp.tzinfo is None:
|
|
86
|
+
stamp = stamp.replace(tzinfo=dt.timezone.utc)
|
|
87
|
+
delta = dt.datetime.now(dt.timezone.utc) - stamp
|
|
88
|
+
minutes = int(delta.total_seconds() // 60)
|
|
89
|
+
if minutes < 60:
|
|
90
|
+
return f"{max(minutes, 0)}m"
|
|
91
|
+
if minutes < 60 * 48:
|
|
92
|
+
return f"{minutes // 60}h"
|
|
93
|
+
return f"{minutes // 1440}d"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def entries():
|
|
97
|
+
out = []
|
|
98
|
+
for entry in os.scandir(handoff_dir()):
|
|
99
|
+
if entry.is_file() and entry.name.endswith(".md"):
|
|
100
|
+
name = entry.name[:-3]
|
|
101
|
+
meta = frontmatter(entry.path)
|
|
102
|
+
out.append((name, entry.path, meta, entry.stat().st_mtime))
|
|
103
|
+
out.sort(key=lambda row: row[3], reverse=True)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve(name):
|
|
108
|
+
exact = file_for(name)
|
|
109
|
+
if os.path.isfile(exact):
|
|
110
|
+
return name, exact
|
|
111
|
+
matches = [row for row in entries() if row[0].startswith(name)]
|
|
112
|
+
if len(matches) == 1:
|
|
113
|
+
return matches[0][0], matches[0][1]
|
|
114
|
+
if not matches:
|
|
115
|
+
sys.exit(f"handoff: no handoff named {name!r}. `handoff.py list --all` shows what exists.")
|
|
116
|
+
sys.exit("handoff: {!r} is ambiguous — {}".format(name, ", ".join(row[0] for row in matches)))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_new(argv):
|
|
120
|
+
if len(argv) != 1:
|
|
121
|
+
sys.exit("handoff: usage: handoff.py new <slug>")
|
|
122
|
+
slug = argv[0]
|
|
123
|
+
if not SLUG.match(slug) or not 3 <= len(slug) <= 60:
|
|
124
|
+
sys.exit(f"handoff: {slug!r} is not a valid slug (lowercase, digits and hyphens, 3-60 chars)")
|
|
125
|
+
name, suffix = slug, 2
|
|
126
|
+
while os.path.exists(file_for(name)):
|
|
127
|
+
name = f"{slug}-{suffix}"
|
|
128
|
+
suffix += 1
|
|
129
|
+
print(name)
|
|
130
|
+
print(file_for(name))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_path(argv):
|
|
134
|
+
if len(argv) != 1:
|
|
135
|
+
sys.exit("handoff: usage: handoff.py path <name>")
|
|
136
|
+
print(resolve(argv[0])[1])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_list(argv):
|
|
140
|
+
show_all = "--all" in argv
|
|
141
|
+
limit = 20
|
|
142
|
+
if "--limit" in argv:
|
|
143
|
+
try:
|
|
144
|
+
limit = int(argv[argv.index("--limit") + 1])
|
|
145
|
+
except (IndexError, ValueError):
|
|
146
|
+
sys.exit("handoff: --limit needs a number")
|
|
147
|
+
here = os.path.realpath(os.getcwd())
|
|
148
|
+
rows = []
|
|
149
|
+
for name, path, meta, _ in entries():
|
|
150
|
+
cwd = os.path.realpath(meta.get("cwd", "")) if meta.get("cwd") else ""
|
|
151
|
+
if not show_all and cwd:
|
|
152
|
+
related = here == cwd or here.startswith(cwd + os.sep) or cwd.startswith(here + os.sep)
|
|
153
|
+
if not related:
|
|
154
|
+
continue
|
|
155
|
+
rows.append((name, age_of(meta.get("created", "")), meta.get("repo", meta.get("cwd", "?")), title_of(path)))
|
|
156
|
+
if len(rows) >= limit:
|
|
157
|
+
break
|
|
158
|
+
if not rows:
|
|
159
|
+
scope = "" if show_all else " for this directory (try --all)"
|
|
160
|
+
print(f"no handoffs{scope}")
|
|
161
|
+
return
|
|
162
|
+
width = max(len(row[0]) for row in rows)
|
|
163
|
+
for name, age, repo, title in rows:
|
|
164
|
+
print(f"{name.ljust(width)} {age.rjust(4)} {repo} {title}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_rm(argv):
|
|
168
|
+
if len(argv) != 1:
|
|
169
|
+
sys.exit("handoff: usage: handoff.py rm <name>")
|
|
170
|
+
name, path = resolve(argv[0])
|
|
171
|
+
os.unlink(path)
|
|
172
|
+
print(f"removed {name}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv):
|
|
176
|
+
commands = {"new": cmd_new, "path": cmd_path, "list": cmd_list, "rm": cmd_rm}
|
|
177
|
+
if not argv or argv[0] not in commands:
|
|
178
|
+
sys.exit(__doc__.strip())
|
|
179
|
+
commands[argv[0]](argv[1:])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main(sys.argv[1:])
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install leos-agent preferences into one harness's global instruction file.
|
|
3
|
+
|
|
4
|
+
Injects the payload from rules/preferences.md into the calling harness's global
|
|
5
|
+
instruction file, wrapped in a <leos-agent> block, and installs any files that
|
|
6
|
+
harness needs but cannot receive through its plugin system.
|
|
7
|
+
|
|
8
|
+
Acts on exactly ONE harness per run -- the one named on the command line. A
|
|
9
|
+
session running in Codex installs Codex and nothing else.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
leo-install.py <harness> [--dry-run | --uninstall | --check] [--force]
|
|
13
|
+
|
|
14
|
+
Harnesses: claude, codex, cursor, hermes, pi, opencode
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import difflib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
HARNESSES = ("claude", "codex", "cursor", "hermes", "pi", "opencode")
|
|
27
|
+
|
|
28
|
+
OPEN_RE = re.compile(r"^<leos-agent\b[^>]*>[ \t]*$", re.MULTILINE)
|
|
29
|
+
CLOSE_RE = re.compile(r"^</leos-agent>[ \t]*$", re.MULTILINE)
|
|
30
|
+
|
|
31
|
+
# Codex concatenates the whole AGENTS.md chain under a byte cap; warn before a
|
|
32
|
+
# global file large enough to start crowding out repo-level instructions.
|
|
33
|
+
CODEX_SOFT_CAP = 28 * 1024
|
|
34
|
+
|
|
35
|
+
# Copied payload files carry this string, which is how uninstall tells its own
|
|
36
|
+
# copies apart from a file the user happens to have put at the same path.
|
|
37
|
+
PROVENANCE = "leos-agent"
|
|
38
|
+
|
|
39
|
+
# OpenCode plugins cannot register skills or commands from JS, so these are
|
|
40
|
+
# copied to disk instead. check.py asserts every file they name carries
|
|
41
|
+
# PROVENANCE, without which the installer would refuse to upgrade its own copy.
|
|
42
|
+
OPENCODE_SKILLS = ("doctor", "review-pr", "handoff", "handon")
|
|
43
|
+
OPENCODE_COMMANDS = ("review-pr", "handoff", "handon")
|
|
44
|
+
|
|
45
|
+
# Codex plugins cannot package custom agent definitions directly, so these are
|
|
46
|
+
# copied into ~/.codex/agents. Keep this tuple authoritative: check.py and the
|
|
47
|
+
# installer tests derive the expected payload from it.
|
|
48
|
+
CODEX_AGENTS = ("leo-runner", "leo-executor")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BlockError(Exception):
|
|
52
|
+
"""The target file's markers are malformed; editing it could destroy content."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Result:
|
|
56
|
+
"""What happened to one target, for the run report."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, target, status, detail="", diff=""):
|
|
59
|
+
self.target = target
|
|
60
|
+
self.status = status
|
|
61
|
+
self.detail = detail
|
|
62
|
+
self.diff = diff
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def changed(self):
|
|
66
|
+
return self.status in ("created", "updated", "removed")
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def failed(self):
|
|
70
|
+
return self.status in ("error", "conflict")
|
|
71
|
+
|
|
72
|
+
def line(self, pending):
|
|
73
|
+
status = self.status
|
|
74
|
+
if pending and self.changed:
|
|
75
|
+
status = {"created": "to create", "updated": "to update", "removed": "to remove"}[status]
|
|
76
|
+
suffix = f" ({self.detail})" if self.detail else ""
|
|
77
|
+
return f" {status:9} {self.target}{suffix}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def plugin_root():
|
|
81
|
+
for name in ("LEOS_AGENT_ROOT", "CLAUDE_PLUGIN_ROOT", "PLUGIN_ROOT"):
|
|
82
|
+
value = os.environ.get(name)
|
|
83
|
+
if not value:
|
|
84
|
+
continue
|
|
85
|
+
root = Path(value).expanduser()
|
|
86
|
+
if (root / "rules" / "preferences.md").is_file():
|
|
87
|
+
return root.resolve()
|
|
88
|
+
return Path(__file__).resolve().parent.parent
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_version(root):
|
|
92
|
+
manifest = root / "package.json"
|
|
93
|
+
try:
|
|
94
|
+
return json.loads(manifest.read_text(encoding="utf-8"))["version"]
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
sys.exit(f"leo-install: {manifest} is missing; the plugin install looks incomplete")
|
|
97
|
+
except json.JSONDecodeError as exc:
|
|
98
|
+
sys.exit(f"leo-install: {manifest} is not valid JSON ({exc})")
|
|
99
|
+
except KeyError:
|
|
100
|
+
sys.exit(f"leo-install: {manifest} has no version field")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def payload_body(root):
|
|
104
|
+
"""The canonical payload: rules/preferences.md with its frontmatter stripped."""
|
|
105
|
+
text = (root / "rules" / "preferences.md").read_text(encoding="utf-8")
|
|
106
|
+
body = re.sub(r"(?s)\A---\n.*?\n---\n", "", text, count=1).strip()
|
|
107
|
+
if not body:
|
|
108
|
+
sys.exit("leo-install: rules/preferences.md has no body below its frontmatter")
|
|
109
|
+
if OPEN_RE.search(body) or CLOSE_RE.search(body):
|
|
110
|
+
sys.exit("leo-install: rules/preferences.md contains a <leos-agent> marker; it must not")
|
|
111
|
+
return body
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_block(root):
|
|
115
|
+
return f'<leos-agent version="{read_version(root)}">\n{payload_body(root)}\n</leos-agent>\n'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def scan_markers(text):
|
|
119
|
+
"""Find marker lines, ignoring any inside a fenced code block.
|
|
120
|
+
|
|
121
|
+
These files are Markdown, and a fenced example showing the block format is a
|
|
122
|
+
perfectly reasonable thing for someone to keep in their own notes. Treating
|
|
123
|
+
such an example as a real marker would either overwrite it or wedge the file
|
|
124
|
+
into a permanent "two blocks" error, so fenced regions are skipped.
|
|
125
|
+
"""
|
|
126
|
+
opens, closes = [], []
|
|
127
|
+
fence = None
|
|
128
|
+
offset = 0
|
|
129
|
+
for line in text.splitlines(keepends=True):
|
|
130
|
+
stripped = line.lstrip()
|
|
131
|
+
marker = stripped[:3]
|
|
132
|
+
if marker in ("```", "~~~"):
|
|
133
|
+
token = marker
|
|
134
|
+
if fence is None:
|
|
135
|
+
fence = token
|
|
136
|
+
elif fence == token:
|
|
137
|
+
fence = None
|
|
138
|
+
elif fence is None:
|
|
139
|
+
if OPEN_RE.match(line.rstrip("\n")):
|
|
140
|
+
opens.append(offset)
|
|
141
|
+
elif CLOSE_RE.match(line.rstrip("\n")):
|
|
142
|
+
closes.append((offset, offset + len(line.rstrip("\n"))))
|
|
143
|
+
offset += len(line)
|
|
144
|
+
return opens, closes
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def find_block(text):
|
|
148
|
+
"""Locate the managed block, refusing to guess when the markers are malformed.
|
|
149
|
+
|
|
150
|
+
Returns (start, end) of the block including its trailing newline, or None if
|
|
151
|
+
the file has no markers at all. Raises BlockError when the markers cannot be
|
|
152
|
+
paired unambiguously -- an unclosed opener, a stray closer, or more than one
|
|
153
|
+
block. Editing in those cases risks swallowing whatever sits between the
|
|
154
|
+
markers, which is exactly the user content this tool must never touch.
|
|
155
|
+
"""
|
|
156
|
+
opens, closes = scan_markers(text)
|
|
157
|
+
if not opens and not closes:
|
|
158
|
+
return None
|
|
159
|
+
if len(opens) != len(closes):
|
|
160
|
+
raise BlockError(
|
|
161
|
+
f"found {len(opens)} <leos-agent> opener(s) and {len(closes)} closer(s); "
|
|
162
|
+
"fix the markers by hand, then re-run"
|
|
163
|
+
)
|
|
164
|
+
if len(opens) > 1:
|
|
165
|
+
raise BlockError(
|
|
166
|
+
f"found {len(opens)} <leos-agent> blocks; keep exactly one, then re-run"
|
|
167
|
+
)
|
|
168
|
+
start, end = opens[0], closes[0][1]
|
|
169
|
+
if end < start:
|
|
170
|
+
raise BlockError("the </leos-agent> closer appears before its opener; fix by hand")
|
|
171
|
+
if end < len(text) and text[end] == "\n":
|
|
172
|
+
end += 1
|
|
173
|
+
return start, end
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def inject(original, block):
|
|
177
|
+
"""Replace the managed block, or append one. Returns the new file content."""
|
|
178
|
+
span = find_block(original)
|
|
179
|
+
if span:
|
|
180
|
+
start, end = span
|
|
181
|
+
return original[:start] + block + original[end:]
|
|
182
|
+
if not original.strip():
|
|
183
|
+
return block
|
|
184
|
+
return original.rstrip("\n") + "\n\n" + block
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def strip_block(original):
|
|
188
|
+
span = find_block(original)
|
|
189
|
+
if not span:
|
|
190
|
+
return original
|
|
191
|
+
start, end = span
|
|
192
|
+
return original[:start] + original[end:]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_text(path):
|
|
196
|
+
"""Read a file, remembering whether it used CRLF so a write can preserve it."""
|
|
197
|
+
raw = path.read_bytes()
|
|
198
|
+
text = raw.decode("utf-8")
|
|
199
|
+
crlf = b"\r\n" in raw
|
|
200
|
+
return (text.replace("\r\n", "\n"), crlf)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def default_mode():
|
|
204
|
+
"""What a normally-created file would get, i.e. 0666 masked by the umask."""
|
|
205
|
+
current = os.umask(0)
|
|
206
|
+
os.umask(current)
|
|
207
|
+
return 0o666 & ~current
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def atomic_write(path, text, crlf):
|
|
211
|
+
"""Write via a temp file in the same directory, then rename over the target.
|
|
212
|
+
|
|
213
|
+
A plain write truncates first, so an interrupted run would leave the user's
|
|
214
|
+
instruction file empty or half-written. The rename is atomic instead.
|
|
215
|
+
"""
|
|
216
|
+
# Write through a symlink rather than over it: an instruction file symlinked
|
|
217
|
+
# into a dotfiles repo must keep pointing there, and os.replace would
|
|
218
|
+
# silently swap the link for a regular file.
|
|
219
|
+
if path.is_symlink():
|
|
220
|
+
path = Path(os.path.realpath(path))
|
|
221
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
data = (text.replace("\n", "\r\n") if crlf else text).encode("utf-8")
|
|
223
|
+
# mkstemp creates 0600; carry over the file's own mode so installing never
|
|
224
|
+
# silently tightens (or loosens) the permissions the user had.
|
|
225
|
+
mode = path.stat().st_mode & 0o777 if path.is_file() else default_mode()
|
|
226
|
+
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=".leo-install-")
|
|
227
|
+
tmp = Path(tmp_name)
|
|
228
|
+
try:
|
|
229
|
+
with os.fdopen(handle, "wb") as fh:
|
|
230
|
+
fh.write(data)
|
|
231
|
+
fh.flush()
|
|
232
|
+
os.fsync(fh.fileno())
|
|
233
|
+
os.chmod(tmp, mode)
|
|
234
|
+
os.replace(tmp, path)
|
|
235
|
+
except BaseException:
|
|
236
|
+
tmp.unlink(missing_ok=True)
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def unified(path, before, after):
|
|
241
|
+
return "".join(
|
|
242
|
+
difflib.unified_diff(
|
|
243
|
+
before.splitlines(keepends=True),
|
|
244
|
+
after.splitlines(keepends=True),
|
|
245
|
+
fromfile=f"{path} (current)",
|
|
246
|
+
tofile=f"{path} (new)",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def write_if_changed(path, new_text, current, existed, crlf, args, label):
|
|
252
|
+
"""Write only when bytes differ. The idempotency guarantee lives here."""
|
|
253
|
+
if existed and current == new_text:
|
|
254
|
+
return Result(label, "unchanged")
|
|
255
|
+
diff = unified(path, current, new_text) if args.dry_run else ""
|
|
256
|
+
if args.writes:
|
|
257
|
+
atomic_write(path, new_text, crlf)
|
|
258
|
+
return Result(label, "updated" if existed else "created", diff=diff)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def install_markdown(path, block, args, label, create=True):
|
|
262
|
+
"""Block-replace in a global instruction file."""
|
|
263
|
+
path = path.expanduser()
|
|
264
|
+
existed = path.is_file()
|
|
265
|
+
if not existed and not create:
|
|
266
|
+
return Result(label, "skipped", f"{path} not found")
|
|
267
|
+
current, crlf = read_text(path) if existed else ("", False)
|
|
268
|
+
|
|
269
|
+
if args.uninstall:
|
|
270
|
+
if not existed or not find_block(current):
|
|
271
|
+
return Result(label, "unchanged", "no block present")
|
|
272
|
+
remainder = strip_block(current)
|
|
273
|
+
if remainder.strip():
|
|
274
|
+
remainder = remainder.rstrip("\n") + "\n"
|
|
275
|
+
diff = unified(path, current, remainder) if args.dry_run else ""
|
|
276
|
+
if args.writes:
|
|
277
|
+
atomic_write(path, remainder, crlf)
|
|
278
|
+
return Result(label, "removed", diff=diff)
|
|
279
|
+
if args.writes:
|
|
280
|
+
path.unlink()
|
|
281
|
+
return Result(label, "removed", "file held nothing else, deleted")
|
|
282
|
+
|
|
283
|
+
return write_if_changed(path, inject(current, block), current, existed, crlf, args, label)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def install_file_copy(src, dest, args, label, owned_parent=False):
|
|
287
|
+
"""Install a payload file the harness's plugin system cannot deliver itself."""
|
|
288
|
+
dest = dest.expanduser()
|
|
289
|
+
existed = dest.is_file()
|
|
290
|
+
payload = src.read_text(encoding="utf-8")
|
|
291
|
+
current = dest.read_text(encoding="utf-8") if existed else ""
|
|
292
|
+
|
|
293
|
+
# Never clobber or delete a same-named file this tool did not put there.
|
|
294
|
+
foreign = existed and current != payload and PROVENANCE not in current
|
|
295
|
+
if foreign and not args.force:
|
|
296
|
+
return Result(label, "conflict", "a file we did not write is already here; re-run with --force to replace it")
|
|
297
|
+
|
|
298
|
+
if args.uninstall:
|
|
299
|
+
if not existed:
|
|
300
|
+
return Result(label, "unchanged", "not present")
|
|
301
|
+
if args.writes:
|
|
302
|
+
dest.unlink()
|
|
303
|
+
if owned_parent and dest.parent.is_dir() and not any(dest.parent.iterdir()):
|
|
304
|
+
dest.parent.rmdir()
|
|
305
|
+
return Result(label, "removed")
|
|
306
|
+
|
|
307
|
+
return write_if_changed(dest, payload, current, existed, False, args, label)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def run(harness, root, args):
|
|
311
|
+
block = build_block(root)
|
|
312
|
+
home = Path.home()
|
|
313
|
+
targets = []
|
|
314
|
+
|
|
315
|
+
if harness == "claude":
|
|
316
|
+
label = "~/.claude/CLAUDE.md"
|
|
317
|
+
targets.append((label, lambda: install_markdown(home / ".claude" / "CLAUDE.md", block, args, label)))
|
|
318
|
+
|
|
319
|
+
elif harness == "codex":
|
|
320
|
+
targets.append(("~/.codex/AGENTS.md", lambda: install_codex_agents_md(home, block, args)))
|
|
321
|
+
for agent_name in CODEX_AGENTS:
|
|
322
|
+
label = f"~/.codex/agents/{agent_name}.toml"
|
|
323
|
+
targets.append(
|
|
324
|
+
(
|
|
325
|
+
label,
|
|
326
|
+
lambda n=agent_name, l=label: install_file_copy(
|
|
327
|
+
root / "payload" / "codex-agents" / f"{n}.toml",
|
|
328
|
+
home / ".codex" / "agents" / f"{n}.toml",
|
|
329
|
+
args,
|
|
330
|
+
l,
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
elif harness == "cursor":
|
|
336
|
+
targets.append(
|
|
337
|
+
(
|
|
338
|
+
"cursor",
|
|
339
|
+
lambda: Result(
|
|
340
|
+
"cursor",
|
|
341
|
+
"skipped",
|
|
342
|
+
"Cursor has no on-disk global rules file; the plugin's alwaysApply rule delivers the payload natively",
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
elif harness == "hermes":
|
|
348
|
+
# Never create SOUL.md: Hermes writes its own starter identity file on
|
|
349
|
+
# first run, and pre-empting that would fight the bootstrap.
|
|
350
|
+
label = "~/.hermes/SOUL.md"
|
|
351
|
+
targets.append(
|
|
352
|
+
(label, lambda: install_markdown(home / ".hermes" / "SOUL.md", block, args, label, create=False))
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
elif harness == "pi":
|
|
356
|
+
label = "~/.pi/agent/AGENTS.md"
|
|
357
|
+
targets.append(
|
|
358
|
+
(label, lambda: install_markdown(home / ".pi" / "agent" / "AGENTS.md", block, args, label))
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
elif harness == "opencode":
|
|
362
|
+
cfg = home / ".config" / "opencode"
|
|
363
|
+
label = "~/.config/opencode/AGENTS.md"
|
|
364
|
+
targets.append((label, lambda: install_markdown(cfg / "AGENTS.md", block, args, label)))
|
|
365
|
+
# OpenCode plugins cannot register skills or commands from JS, so the
|
|
366
|
+
# payload files are copied into the config dir where it reads them.
|
|
367
|
+
skill_label = "~/.config/opencode/skills/leo-install/SKILL.md"
|
|
368
|
+
targets.append(
|
|
369
|
+
(
|
|
370
|
+
skill_label,
|
|
371
|
+
lambda: install_file_copy(
|
|
372
|
+
root / "skills" / "install" / "SKILL.md",
|
|
373
|
+
cfg / "skills" / "leo-install" / "SKILL.md",
|
|
374
|
+
args,
|
|
375
|
+
skill_label,
|
|
376
|
+
owned_parent=True,
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
# The remaining skills and their commands, same reason. Bound late via a
|
|
381
|
+
# default argument: a lambda closing over the loop variable would copy
|
|
382
|
+
# the last entry every time.
|
|
383
|
+
for skill_name in OPENCODE_SKILLS:
|
|
384
|
+
# reference/ files first, so their directory is gone by the time the
|
|
385
|
+
# SKILL.md target tries to remove the now-empty skill directory.
|
|
386
|
+
for ref in sorted((root / "skills" / skill_name / "reference").glob("*.md")):
|
|
387
|
+
ref_label = f"~/.config/opencode/skills/{skill_name}/reference/{ref.name}"
|
|
388
|
+
targets.append(
|
|
389
|
+
(
|
|
390
|
+
ref_label,
|
|
391
|
+
lambda s=ref, n=skill_name, l=ref_label: install_file_copy(
|
|
392
|
+
s,
|
|
393
|
+
cfg / "skills" / n / "reference" / s.name,
|
|
394
|
+
args,
|
|
395
|
+
l,
|
|
396
|
+
owned_parent=True,
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
extra_label = f"~/.config/opencode/skills/{skill_name}/SKILL.md"
|
|
401
|
+
targets.append(
|
|
402
|
+
(
|
|
403
|
+
extra_label,
|
|
404
|
+
lambda n=skill_name, l=extra_label: install_file_copy(
|
|
405
|
+
root / "skills" / n / "SKILL.md",
|
|
406
|
+
cfg / "skills" / n / "SKILL.md",
|
|
407
|
+
args,
|
|
408
|
+
l,
|
|
409
|
+
owned_parent=True,
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
for command_name in OPENCODE_COMMANDS:
|
|
414
|
+
command_label = f"~/.config/opencode/commands/{command_name}.md"
|
|
415
|
+
targets.append(
|
|
416
|
+
(
|
|
417
|
+
command_label,
|
|
418
|
+
lambda n=command_name, l=command_label: install_file_copy(
|
|
419
|
+
root / "commands" / f"{n}.md",
|
|
420
|
+
cfg / "commands" / f"{n}.md",
|
|
421
|
+
args,
|
|
422
|
+
l,
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Each target reports on its own. One failure must not discard the report
|
|
428
|
+
# for the others, or hide what already landed.
|
|
429
|
+
results = []
|
|
430
|
+
for label, target in targets:
|
|
431
|
+
try:
|
|
432
|
+
results.append(target())
|
|
433
|
+
except BlockError as exc:
|
|
434
|
+
results.append(Result(label, "error", str(exc)))
|
|
435
|
+
except UnicodeDecodeError:
|
|
436
|
+
results.append(Result(label, "error", "not valid UTF-8 text; refusing to rewrite it"))
|
|
437
|
+
except OSError as exc:
|
|
438
|
+
results.append(Result(label, "error", exc.strerror or str(exc)))
|
|
439
|
+
return results
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def install_codex_agents_md(home, block, args):
|
|
443
|
+
"""Codex's AGENTS.md, with a warning when the result nears the chain cap."""
|
|
444
|
+
path = home / ".codex" / "AGENTS.md"
|
|
445
|
+
result = install_markdown(path, block, args, "~/.codex/AGENTS.md")
|
|
446
|
+
if args.uninstall or result.status in ("skipped", "error"):
|
|
447
|
+
return result
|
|
448
|
+
current = read_text(path)[0] if path.is_file() else ""
|
|
449
|
+
prospective = current if result.status == "unchanged" else inject(current, block)
|
|
450
|
+
size = len(prospective.encode("utf-8"))
|
|
451
|
+
if size > CODEX_SOFT_CAP:
|
|
452
|
+
result.detail = f"WARNING: {size} bytes, near Codex's 32KiB chain cap"
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def main(argv=None):
|
|
457
|
+
parser = argparse.ArgumentParser(
|
|
458
|
+
prog="leo-install.py",
|
|
459
|
+
description="Install leos-agent preferences into ONE harness's global instruction file.",
|
|
460
|
+
)
|
|
461
|
+
parser.add_argument("harness", choices=HARNESSES, help="the harness this session is running in")
|
|
462
|
+
mode = parser.add_mutually_exclusive_group()
|
|
463
|
+
mode.add_argument("--dry-run", action="store_true", help="show diffs, write nothing")
|
|
464
|
+
mode.add_argument("--uninstall", action="store_true", help="remove the block and any installed payload files")
|
|
465
|
+
mode.add_argument("--check", action="store_true", help="exit 1 if anything would change")
|
|
466
|
+
parser.add_argument("--force", action="store_true", help="replace a conflicting file this tool did not write")
|
|
467
|
+
args = parser.parse_args(argv)
|
|
468
|
+
args.writes = not (args.dry_run or args.check)
|
|
469
|
+
|
|
470
|
+
root = plugin_root()
|
|
471
|
+
if not (root / "rules" / "preferences.md").is_file():
|
|
472
|
+
sys.exit(f"leo-install: cannot find the plugin payload from {root}; set LEOS_AGENT_ROOT")
|
|
473
|
+
|
|
474
|
+
version = read_version(root)
|
|
475
|
+
results = run(args.harness, root, args)
|
|
476
|
+
|
|
477
|
+
if args.uninstall:
|
|
478
|
+
verb = "removing"
|
|
479
|
+
elif args.writes:
|
|
480
|
+
verb = "installing"
|
|
481
|
+
else:
|
|
482
|
+
verb = "would install"
|
|
483
|
+
print(f"leos-agent {version} — {verb} {args.harness}")
|
|
484
|
+
for res in results:
|
|
485
|
+
print(res.line(pending=not args.writes))
|
|
486
|
+
if res.diff:
|
|
487
|
+
print("".join(f" {ln}" for ln in res.diff.splitlines(keepends=True)))
|
|
488
|
+
|
|
489
|
+
changed = [r for r in results if r.changed]
|
|
490
|
+
failed = [r for r in results if r.failed]
|
|
491
|
+
|
|
492
|
+
if failed:
|
|
493
|
+
print(f"\n{len(failed)} target(s) could not be handled; nothing further was attempted for them")
|
|
494
|
+
return 1
|
|
495
|
+
if args.check:
|
|
496
|
+
if changed:
|
|
497
|
+
print(f"\n{len(changed)} target(s) out of date; run leo-install.py {args.harness}")
|
|
498
|
+
return 1
|
|
499
|
+
print("\nup to date")
|
|
500
|
+
return 0
|
|
501
|
+
if args.dry_run:
|
|
502
|
+
print(f"\n{len(changed)} target(s) would change; nothing written")
|
|
503
|
+
else:
|
|
504
|
+
print(f"\n{len(changed)} target(s) changed")
|
|
505
|
+
return 0
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
sys.exit(main())
|