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.
Files changed (96) hide show
  1. package/README.md +544 -28
  2. package/commands/handoff.md +11 -0
  3. package/commands/handon.md +10 -0
  4. package/commands/review-pr.md +9 -0
  5. package/commands-claude/watch-review.md +9 -0
  6. package/index.js +12 -0
  7. package/package.json +29 -14
  8. package/payload/codex-agents/leo-executor.toml +36 -0
  9. package/payload/codex-agents/leo-runner.toml +28 -0
  10. package/rules/preferences.md +97 -0
  11. package/scripts/check.py +244 -0
  12. package/scripts/ghreview.py +17 -3
  13. package/scripts/handoff.py +183 -0
  14. package/scripts/leo-install.py +509 -0
  15. package/scripts/measure_context.py +113 -0
  16. package/scripts/publish-npm.py +138 -0
  17. package/scripts/watch_review.py +169 -0
  18. package/skills/doctor/SKILL.md +73 -115
  19. package/skills/doctor/agents/openai.yaml +5 -0
  20. package/skills/handoff/SKILL.md +99 -0
  21. package/skills/handoff/agents/openai.yaml +5 -0
  22. package/skills/handon/SKILL.md +61 -0
  23. package/skills/install/SKILL.md +79 -0
  24. package/skills/install/agents/openai.yaml +5 -0
  25. package/skills/review-pr/SKILL.md +59 -318
  26. package/skills/review-pr/reference/lenses.md +67 -0
  27. package/skills/review-pr/reference/procedure.md +348 -0
  28. package/skills-claude/attach-pr/SKILL.md +178 -0
  29. package/skills-claude/watch-review/SKILL.md +91 -0
  30. package/adapters/cursor/agents/executor.md +0 -17
  31. package/adapters/cursor/agents/expert.md +0 -70
  32. package/adapters/cursor/agents/explore.md +0 -16
  33. package/adapters/cursor/agents/implementer.md +0 -18
  34. package/adapters/cursor/agents/investigator.md +0 -18
  35. package/adapters/cursor/agents/planner.md +0 -28
  36. package/adapters/cursor/agents/review-lens.md +0 -22
  37. package/adapters/cursor/agents/reviewer.md +0 -34
  38. package/adapters/opencode/agents.json +0 -105
  39. package/adapters/opencode/plugin.js +0 -576
  40. package/config/MCP_PINS.md +0 -17
  41. package/config/models.json +0 -676
  42. package/hooks/bash-guard.py +0 -583
  43. package/hooks/cursor-guard.py +0 -84
  44. package/hooks/hooks-cursor.json +0 -11
  45. package/hooks/hooks.json +0 -20
  46. package/hooks/session-start.py +0 -148
  47. package/roles/executor.md +0 -15
  48. package/roles/expert.md +0 -67
  49. package/roles/explore.md +0 -13
  50. package/roles/implementer.md +0 -16
  51. package/roles/investigator.md +0 -15
  52. package/roles/planner.md +0 -25
  53. package/roles/review-lens.md +0 -20
  54. package/roles/reviewer.md +0 -31
  55. package/scripts/doctor.py +0 -520
  56. package/scripts/jsonc_bridge.cjs +0 -23
  57. package/scripts/memory.py +0 -744
  58. package/scripts/render_adapters.py +0 -508
  59. package/scripts/setup.py +0 -1753
  60. package/settings.json +0 -7
  61. package/skills/.gitkeep +0 -0
  62. package/skills/brainstorming/SKILL.md +0 -111
  63. package/skills/debugging/SKILL.md +0 -100
  64. package/skills/delegation/SKILL.md +0 -143
  65. package/skills/executing-plans/SKILL.md +0 -117
  66. package/skills/finishing-a-branch/SKILL.md +0 -125
  67. package/skills/freshness/SKILL.md +0 -131
  68. package/skills/memory/SKILL.md +0 -154
  69. package/skills/resolve-ticket/SKILL.md +0 -275
  70. package/skills/setup/SKILL.md +0 -199
  71. package/skills/setup/agents/openai.yaml +0 -5
  72. package/skills/test-first/SKILL.md +0 -92
  73. package/skills/using-leo/SKILL.md +0 -101
  74. package/skills/using-leo/references/claude-mapping.md +0 -33
  75. package/skills/using-leo/references/codex-mapping.md +0 -33
  76. package/skills/using-leo/references/cursor-mapping.md +0 -35
  77. package/skills/using-leo/references/hermes-mapping.md +0 -37
  78. package/skills/using-leo/references/opencode-mapping.md +0 -39
  79. package/skills/verification/SKILL.md +0 -110
  80. package/skills/visual-verification/SKILL.md +0 -115
  81. package/skills/watch-review/SKILL.md +0 -128
  82. package/skills/watch-review/agents/openai.yaml +0 -5
  83. package/skills/worktrees/SKILL.md +0 -131
  84. package/skills/writing-plans/SKILL.md +0 -97
  85. package/skills/writing-skills/SKILL.md +0 -141
  86. package/vendor/jsonc-parser-3.3.1/LICENSE.md +0 -21
  87. package/vendor/jsonc-parser-3.3.1/README.md +0 -26
  88. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +0 -201
  89. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +0 -275
  90. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +0 -682
  91. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +0 -456
  92. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +0 -42
  93. package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +0 -351
  94. package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +0 -194
  95. package/vendor/jsonc-parser-3.3.1/package.json +0 -37
  96. 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())