margin 0.2.1__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.
margin/__init__.py ADDED
File without changes
margin/batch.py ADDED
@@ -0,0 +1,41 @@
1
+ """When to tell the agent about comments: a settle window, so a burst becomes one message.
2
+
3
+ A route (one repo's chat) has a batch open from its first undelivered comment. The batch is ready
4
+ when the person has been quiet for `quiet` seconds, or `cap` seconds after its first comment so a
5
+ long reading session still gets answers, or at once when a comment mentions the agent.
6
+
7
+ Times are this machine's receive times (seconds since the epoch), never the server's timestamps,
8
+ so a clock or timezone difference between the two cannot hold a batch back or release it early.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from collections import defaultdict
13
+
14
+
15
+ def ready(rows, now: float, quiet: float, cap: float) -> dict[str, list]:
16
+ by_route: dict[str, list] = defaultdict(list)
17
+ for r in rows:
18
+ by_route[r["route"]].append(r)
19
+ out = {}
20
+ for route, rs in by_route.items():
21
+ first = min(r["received_at"] for r in rs)
22
+ last = max(r["received_at"] for r in rs)
23
+ if any(r["mentions_agent"] for r in rs) or now - last >= quiet or now - first >= cap:
24
+ out[route] = sorted(rs, key=lambda r: r["received_at"])
25
+ return out
26
+
27
+
28
+ def due_in(rows, now: float, quiet: float, cap: float) -> float | None:
29
+ """Seconds until the next batch becomes ready, for a status line. None when nothing waits."""
30
+ if not rows:
31
+ return None
32
+ best = None
33
+ by_route: dict[str, list] = defaultdict(list)
34
+ for r in rows:
35
+ by_route[r["route"]].append(r)
36
+ for rs in by_route.values():
37
+ first = min(r["received_at"] for r in rs)
38
+ last = max(r["received_at"] for r in rs)
39
+ t = max(0.0, min(last + quiet, first + cap) - now)
40
+ best = t if best is None else min(best, t)
41
+ return best
margin/claude.py ADDED
@@ -0,0 +1,89 @@
1
+ """`margin setup-claude`: wire margin and Outline into Claude Code, idempotently.
2
+
3
+ Three things, each safe to run again:
4
+
5
+ 1. the margin skill, linked into ~/.claude/skills/margin from this package, so it updates with it;
6
+ 2. Outline's own MCP server registered in ~/.claude.json, with the agent's key supplied at connect
7
+ time by a small headers script that runs `api_key_command`, so the key is never written into
8
+ a config file;
9
+ 3. Outline's destructive MCP tools denied in ~/.claude/settings.json. An agent that deletes or
10
+ moves a document, or changes a collection, would break the folder structure margin mirrors.
11
+
12
+ Both JSON files are backed up once per run before they are rewritten, atomically.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import shlex
19
+ import shutil
20
+ import tempfile
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from .config import Config
25
+
26
+ DENY = ["delete_document", "delete_collection", "update_collection", "create_collection",
27
+ "move_document"]
28
+
29
+
30
+ def _rewrite(path: Path, change) -> bool:
31
+ data = json.loads(path.read_text()) if path.exists() else {}
32
+ before = json.dumps(data, sort_keys=True)
33
+ change(data)
34
+ if json.dumps(data, sort_keys=True) == before:
35
+ return False
36
+ if path.exists():
37
+ shutil.copy2(path, path.with_name(path.name + f".bak-margin-{time.strftime('%Y%m%d%H%M%S')}"))
38
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent))
39
+ with os.fdopen(fd, "w") as f:
40
+ json.dump(data, f, indent=2)
41
+ os.replace(tmp, path)
42
+ return True
43
+
44
+
45
+ def setup(cfg: Config, home: Path | None = None, name: str = "outline") -> list[str]:
46
+ home = home or Path.home()
47
+ done = []
48
+
49
+ # 1. the skill
50
+ skill_src = Path(__file__).parent / "skills" / "margin"
51
+ skills = home / ".claude" / "skills"
52
+ skills.mkdir(parents=True, exist_ok=True)
53
+ link = skills / "margin"
54
+ if link.is_symlink() or not link.exists():
55
+ if not (link.is_symlink() and Path(os.readlink(link)) == skill_src):
56
+ if link.is_symlink():
57
+ link.unlink()
58
+ link.symlink_to(skill_src)
59
+ done.append(f"skill linked: {link} -> {skill_src}")
60
+ else:
61
+ done.append(f"skill left alone: {link} exists and is not a link")
62
+
63
+ # 2. the MCP server and its headers script
64
+ helper = home / ".config" / "margin" / "outline-mcp-headers.sh"
65
+ helper.parent.mkdir(parents=True, exist_ok=True)
66
+ body = ("#!/bin/sh\n# written by margin setup-claude: the agent's Outline key, read at connect time\n"
67
+ f"k=$({shlex.join(cfg.api_key_command)}) || exit 1\n"
68
+ "printf '{\"Authorization\": \"Bearer %s\"}\\n' \"$k\"\n")
69
+ if not helper.exists() or helper.read_text() != body:
70
+ helper.write_text(body)
71
+ done.append(f"headers script written: {helper}")
72
+ helper.chmod(0o700)
73
+ url = cfg.mcp_url or cfg.url.rstrip("/") + "/mcp"
74
+
75
+ def add_server(d):
76
+ d.setdefault("mcpServers", {})[name] = {"type": "http", "url": url,
77
+ "headersHelper": str(helper)}
78
+ if _rewrite(home / ".claude.json", add_server):
79
+ done.append(f"MCP server '{name}' registered at {url}")
80
+
81
+ # 3. deny the destructive tools
82
+ def deny(d):
83
+ lst = d.setdefault("permissions", {}).setdefault("deny", [])
84
+ for t in DENY:
85
+ if f"mcp__{name}__{t}" not in lst:
86
+ lst.append(f"mcp__{name}__{t}")
87
+ if _rewrite(home / ".claude" / "settings.json", deny):
88
+ done.append("destructive Outline tools denied: " + ", ".join(DENY))
89
+ return done or ["already set up"]
margin/cli.py ADDED
@@ -0,0 +1,294 @@
1
+ """margin: publish repo markdown to Outline and bring comments back to an agent.
2
+
3
+ margin sync [REPO...] [--full] publish changed files (all repos when none named)
4
+ margin serve webhook receiver + catch-up poll + batching + periodic sync
5
+ margin webhook create Outline's webhook subscription if it is missing
6
+ margin status documents that need attention, and comments waiting
7
+ margin reply COMMENT_ID TEXT answer in the comment's thread, as the configured user
8
+ margin resolve COMMENT_ID mark the thread resolved
9
+ margin thread COMMENT_ID print a whole thread with its quoted text
10
+ margin link FILE... publish these files now and print a Markdown link to each
11
+ margin setup-claude install the skill, Outline's MCP and its tool denials
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import json
18
+ import sys
19
+ import time
20
+
21
+ from . import batch
22
+ from .config import DEFAULT_PATH, load, locate
23
+ from .inbox import EVENTS, Inbox, serve
24
+ from .outline import Outline, comment_text, pin_host
25
+ from .store import Store
26
+ from .sync import Syncer
27
+
28
+
29
+ def connect(cfg):
30
+ if cfg.pin_ip_command:
31
+ from urllib.parse import urlparse
32
+ pin_host(urlparse(cfg.url).hostname, cfg.run(cfg.pin_ip_command).split()[0])
33
+ return Outline(cfg.url, cfg.api_key())
34
+
35
+
36
+ def ensure_webhook(cfg, o: Outline) -> str:
37
+ """Webhook subscriptions need an admin. The agent's own key is a member's, on purpose, so the
38
+ admin key is used for this one call only and never kept on the client."""
39
+ if not (cfg.webhook_url and cfg.secret_command):
40
+ return "no webhook configured; the catch-up poll is the only path"
41
+ if not cfg.admin_key_command:
42
+ return "no admin_key_command; create the webhook once as an admin (margin webhook)"
43
+ admin = Outline(cfg.url, cfg.run(cfg.admin_key_command))
44
+ w = admin.ensure_webhook("margin", cfg.webhook_url, cfg.secret(), EVENTS)
45
+ return f"webhook {w['id']} -> {w['url']} ({', '.join(w.get('events') or [])})"
46
+
47
+
48
+ def thread_of(o: Outline, comment_id: str) -> tuple[dict, list[dict]]:
49
+ c = o.comment(comment_id)
50
+ root = o.comment(c["parentCommentId"]) if c.get("parentCommentId") else c
51
+ rows = [x for x in o.comments(document_id=root["documentId"])
52
+ if x["id"] == root["id"] or x.get("parentCommentId") == root["id"]]
53
+ return root, sorted(rows, key=lambda x: x["createdAt"])
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ ap = argparse.ArgumentParser(prog="margin", description=__doc__,
58
+ formatter_class=argparse.RawDescriptionHelpFormatter)
59
+ ap.add_argument("--config", default=str(DEFAULT_PATH))
60
+ sub = ap.add_subparsers(dest="cmd", required=True)
61
+ p = sub.add_parser("sync")
62
+ p.add_argument("repos", nargs="*")
63
+ p.add_argument("--full", action="store_true")
64
+ sub.add_parser("serve")
65
+ sub.add_parser("webhook")
66
+ sub.add_parser("status")
67
+ p = sub.add_parser("reply")
68
+ p.add_argument("comment_id")
69
+ p.add_argument("text")
70
+ p = sub.add_parser("resolve")
71
+ p.add_argument("comment_id")
72
+ p = sub.add_parser("thread")
73
+ p.add_argument("comment_id")
74
+ p = sub.add_parser("link")
75
+ p.add_argument("files", nargs="+")
76
+ p = sub.add_parser("accept-file", help="let the file win a conflict no person made")
77
+ p.add_argument("files", nargs="+")
78
+ p = sub.add_parser("accept-merged",
79
+ help="publish a file that now carries a person's Outline edit")
80
+ p.add_argument("files", nargs="+")
81
+ p.add_argument("--undo", action="append", default=[],
82
+ help="text from a part of their edit the person agreed to undo (repeatable)")
83
+ sub.add_parser("setup-claude")
84
+ sub.add_parser("anchors", help="record where each older comment's passage sits in its document")
85
+ p = sub.add_parser("pinch-check", help="pinch a fold page on an Android phone over adb and judge each pinch")
86
+ p.add_argument("page", help="the fold page (margin fold --html)")
87
+ p.add_argument("--serial", required=True, help="the phone, as adb names it")
88
+ p.add_argument("--package", default="com.brave.browser", help="the phone's Chromium browser")
89
+ p.add_argument("--out", help="the folder for the table, the readings and the recording")
90
+ p.add_argument("--wait-unlock", type=float, default=0.0,
91
+ help="seconds to wait for the phone to be awake and unlocked (never unlocked for you)")
92
+ p = sub.add_parser("fold", help="fold a markdown file into five levels of detail, each checked")
93
+ p.add_argument("file")
94
+ p.add_argument("--out", help="write the fold as JSON here")
95
+ p.add_argument("--html", help="write a page to read the fold here")
96
+ p.add_argument("--model", default="sonnet")
97
+ p.add_argument("--judge-model", default="opus")
98
+ a = ap.parse_args(argv)
99
+
100
+ if a.cmd == "pinch-check":
101
+ from pathlib import Path as P
102
+ from .pinch_check import run as pinch_run
103
+ return pinch_run(P(a.page).expanduser().resolve(), a.serial, package=a.package,
104
+ out=P(a.out).expanduser() if a.out else None, wait_unlock=a.wait_unlock)
105
+
106
+ if a.cmd == "fold":
107
+ from functools import partial
108
+ from pathlib import Path as P
109
+ from .fold import ask_claude, ask_judge, fold_file
110
+ cache = P(os.environ.get("MARGIN_FOLD_CACHE", "~/.cache/margin/folds")).expanduser()
111
+ f = fold_file(P(a.file), out=P(a.out) if a.out else None, html_out=P(a.html) if a.html else None,
112
+ cache=cache, ask=partial(ask_claude, model=a.model),
113
+ judge=partial(ask_judge, model=a.judge_model))
114
+ c = f["counts"]
115
+ print(f"folded {a.file}: {c['units']} paragraphs, {c['claude']} by Claude, "
116
+ f"{c['author']} kept in the author's words, {c['calls']} calls")
117
+ for s in f["sections"]:
118
+ for u in s["units"]:
119
+ if u["source"] == "author":
120
+ print(f" author's words: {u['id']}: {'; '.join(u['refused'])}")
121
+ if s["section"]["source"] == "author":
122
+ print(f" section in the author's words: {s['id']}: {'; '.join(s['section']['refused'])}")
123
+ return 0
124
+
125
+ from pathlib import Path
126
+ cfg = load(Path(a.config).expanduser())
127
+ if a.cmd == "setup-claude":
128
+ from .claude import setup
129
+ for line in setup(cfg):
130
+ print(line)
131
+ return 0
132
+ o = connect(cfg)
133
+ store = Store(cfg.state)
134
+
135
+ def sync_all(names=(), full=False, quiet=True):
136
+ out = []
137
+ for repo in cfg.repos:
138
+ if names and repo.name not in names:
139
+ continue
140
+ for r in Syncer(o, store, repo, full=full).run():
141
+ if not quiet or r.action != "unchanged":
142
+ out.append((repo.name, r))
143
+ return out
144
+
145
+ if a.cmd == "sync":
146
+ unknown = set(a.repos) - {r.name for r in cfg.repos}
147
+ if unknown:
148
+ print(f"unknown repo(s): {', '.join(sorted(unknown))}", file=sys.stderr)
149
+ return 2
150
+ res = sync_all(a.repos, a.full)
151
+ for repo, r in res:
152
+ print(f"{r.action:18} {repo}/{r.path}" + (f" ({r.detail})" if r.detail else ""))
153
+ if not res:
154
+ print("nothing changed")
155
+ return 1 if any(r.action in ("error", "cannot-patch") for _, r in res) else 0
156
+
157
+ if a.cmd == "serve":
158
+ # Outline may still be starting (a 502 from its proxy): wait for it instead of exiting
159
+ o.wait_ready(log=lambda m: print(m, file=sys.stderr, flush=True))
160
+ inbox = Inbox(cfg, o, store)
161
+ try:
162
+ print(ensure_webhook(cfg, o), flush=True)
163
+ except Exception as e: # the poll still delivers without the webhook
164
+ print(f"webhook not ensured: {e}", file=sys.stderr, flush=True)
165
+
166
+ def sync_pass():
167
+ for repo, r in sync_all():
168
+ print(f"sync: {r.action} {repo}/{r.path}" + (f" ({r.detail})" if r.detail else ""),
169
+ flush=True)
170
+ # a changed text moves its passages, and a rewritten one loses its mark
171
+ row = store.doc(repo, r.path) if r.action in ("patched", "outline-edited") else None
172
+ if row:
173
+ inbox.place(row["doc_id"])
174
+
175
+ serve(inbox, sync_pass)
176
+ return 0
177
+
178
+ if a.cmd == "webhook":
179
+ print(ensure_webhook(cfg, o))
180
+ return 0
181
+
182
+ if a.cmd == "status":
183
+ bad = [d for d in store.docs() if d["status"] != "ok"]
184
+ for d in bad:
185
+ print(f"{d['status']:18} {d['repo']}/{d['path']} {d['detail'] or ''}")
186
+ pend = store.pending()
187
+ wait = batch.due_in(pend, time.time(), cfg.quiet_seconds, cfg.cap_seconds)
188
+ print(f"{len(store.docs())} documents, {len(bad)} need attention, {len(pend)} comment(s) "
189
+ "waiting" + (f", next batch in {int(wait)} s" if wait is not None else ""))
190
+ return 0
191
+
192
+ if a.cmd == "anchors":
193
+ inbox = Inbox(cfg, o, store)
194
+ done = gone = 0
195
+ for doc_id in dict.fromkeys(r["document_id"] for r in store.without_anchor_before()):
196
+ p, g = inbox.place(doc_id)
197
+ done, gone = done + p, gone + g
198
+ print(f"{done} passage(s) placed, {gone} with no mark left in their document")
199
+ return 0
200
+
201
+ if a.cmd == "reply":
202
+ c = o.comment(a.comment_id)
203
+ root = c.get("parentCommentId") or c["id"]
204
+ r = o.reply(c["documentId"], root, a.text)
205
+ print(f"replied in thread {root} as {o.me()['name']}: {r['id']}")
206
+ return 0
207
+
208
+ if a.cmd == "resolve":
209
+ c = o.comment(a.comment_id)
210
+ o.resolve(c.get("parentCommentId") or c["id"])
211
+ print("resolved")
212
+ return 0
213
+
214
+ if a.cmd == "link":
215
+ from .outline import doc_url
216
+ by_repo: dict = {}
217
+ bad = 0
218
+ for f in a.files:
219
+ hit = locate(cfg, Path(f))
220
+ if not hit:
221
+ print(f"not published: {f} is in no configured repo (nor in a git worktree of "
222
+ "one). Put it in a repo margin publishes, or add its repo to the config.",
223
+ file=sys.stderr)
224
+ bad = 1
225
+ continue
226
+ by_repo.setdefault((hit[0].name, hit[2]), (hit[0], []))[1].append(hit[1])
227
+ author = cfg.author() # the comments on what you link come back to you
228
+ for (name, root), (repo, rels) in by_repo.items():
229
+ got = {rel: (d, st, det)
230
+ for rel, d, st, det in Syncer(o, store, repo).publish_status(rels, root)}
231
+ for rel, (d, _, _) in got.items():
232
+ if d and author:
233
+ store.put(f"author:{d['id']}", author)
234
+ for rel in rels:
235
+ d, status, detail = got.get(rel, (None, "", ""))
236
+ if d and status == "ok":
237
+ print(f"[{d['title']}]({doc_url(cfg.url, d)})")
238
+ elif d:
239
+ # The document exists but does not say what the file says. A link printed here
240
+ # read as "published" and a chat reported a section that never reached Outline.
241
+ # ⭐ which kind it is, so a chat neither stops to ask about an edit nobody made
242
+ # nor forces its file over one a person did make (2026-10-16)
243
+ why = (" Nobody edited it: margin could not patch it into the file's text. "
244
+ "`margin accept-file` publishes the file when only margin has edited it."
245
+ if status in ("cannot-patch", "drift") else
246
+ " Someone may have edited it in Outline: `margin accept-file` publishes the "
247
+ "file only if nobody did, and otherwise names who did.")
248
+ print(f"not published: {name}/{rel} is {status}"
249
+ + (f" ({detail})" if detail else "")
250
+ + f". The document still has the older text: [{d['title']}]"
251
+ f"({doc_url(cfg.url, d)}).{why}", file=sys.stderr)
252
+ bad = 1
253
+ else:
254
+ print(f"not published: {name}/{rel} is excluded by the repo's include or "
255
+ "exclude patterns, or is not markdown", file=sys.stderr)
256
+ bad = 1
257
+ return bad
258
+
259
+ if a.cmd in ("accept-file", "accept-merged"):
260
+ from .outline import doc_url
261
+ bad = 0
262
+ for f in a.files:
263
+ hit = locate(cfg, Path(f))
264
+ if not hit:
265
+ print(f"refused: {f} is in no configured repo", file=sys.stderr)
266
+ bad = 1
267
+ continue
268
+ s = Syncer(o, store, hit[0])
269
+ if a.cmd == "accept-file":
270
+ r = s.accept_file(hit[1], hit[2])
271
+ else:
272
+ r = s.accept_merged(hit[1], hit[2], tuple(a.undo))
273
+ row = store.doc(hit[0].name, r.path)
274
+ if r.action in ("patched", "unchanged") and row and row["status"] == "ok":
275
+ d = o.document(row["doc_id"])
276
+ print(f"accepted the file: [{d['title']}]({doc_url(cfg.url, d)})")
277
+ else:
278
+ print(f"refused: {hit[0].name}/{hit[1]} ({r.action}"
279
+ + (f": {r.detail}" if r.detail else "") + ")", file=sys.stderr)
280
+ bad = 1
281
+ return bad
282
+
283
+ if a.cmd == "thread":
284
+ root, rows = thread_of(o, a.comment_id)
285
+ if root.get("anchorText"):
286
+ print(f'on "{root["anchorText"]}"')
287
+ for x in rows:
288
+ print(f"[{x['id']}] {x['createdBy']['name']}: {comment_text(x.get('data'))}")
289
+ return 0
290
+ return 2
291
+
292
+
293
+ if __name__ == "__main__":
294
+ raise SystemExit(main())
margin/config.py ADDED
@@ -0,0 +1,188 @@
1
+ """margin's configuration: one TOML file, by default ~/.config/margin/config.toml.
2
+
3
+ [outline]
4
+ url = "https://docs.example.com"
5
+ api_key_command = ["security", "find-generic-password", "-s", "outline-api-key", "-w"]
6
+ pin_ip_command = ["tailscale", "ip", "-4"] # optional: resolve the url's host to this IP
7
+ admin_key_command = [...] # optional: an admin's key, used ONLY to create the webhook
8
+ mcp_url = "http://127.0.0.1:3100/mcp" # optional: Outline's MCP, default <url>/mcp
9
+
10
+ [intake]
11
+ listen = "127.0.0.1:8771"
12
+ webhook_url = "http://host.docker.internal:8771/outline" # what Outline posts to
13
+ secret_command = ["security", "find-generic-password", "-s", "margin-webhook-secret", "-w"]
14
+ poll_seconds = 600
15
+
16
+ [batch]
17
+ quiet_seconds = 180
18
+ cap_seconds = 900
19
+
20
+ [notify]
21
+ command = ["my-notifier", "{route}"] # gets the batch as JSON on stdin. {text} is the
22
+ cwd = "~/somewhere" # rendered message, {repo} and {repo_path} name the
23
+ # repo of the batch. cwd is optional.
24
+ author_command = ["who-am-i"] # optional: prints the route of whoever runs
25
+ # `margin link`; their documents' comments go there
26
+
27
+ [[repo]]
28
+ name = "my-project" # becomes the Outline collection
29
+ path = "~/code/my-project"
30
+ include = ["**/*.md"]
31
+ exclude = ["**/node_modules/**"]
32
+ route = "my-project" # passed to the notifier as {route}
33
+ routes = { "docs/design/" = "design" } # optional: by path prefix, longest wins
34
+ # A document no route names goes to whoever last
35
+ # linked it (author_command), else to `route`.
36
+
37
+ Secrets are never written in the file. Each one is the output of a command.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import os
42
+ import subprocess
43
+ import tomllib
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+
47
+ DEFAULT_PATH = Path(os.environ.get("MARGIN_CONFIG", "~/.config/margin/config.toml")).expanduser()
48
+ DEFAULT_STATE = Path(os.environ.get("MARGIN_STATE", "~/.local/state/margin/margin.db")).expanduser()
49
+
50
+ ALWAYS_EXCLUDE = ["**/.git/**", "**/node_modules/**", "**/.venv/**", "**/venv/**",
51
+ "**/__pycache__/**", "**/.playwright-mcp/**", "**/dist/**", "**/build/**",
52
+ "**/.pytest_cache/**", "**/.mypy_cache/**", "**/.ruff_cache/**", "**/.tox/**",
53
+ "**/*.egg-info/**", "**/.eggs/**", "**/.history/**", "**/site-packages/**"]
54
+
55
+
56
+ @dataclass
57
+ class Repo:
58
+ name: str
59
+ path: Path
60
+ include: list[str] = field(default_factory=lambda: ["**/*.md"])
61
+ exclude: list[str] = field(default_factory=list)
62
+ route: str = ""
63
+ routes: dict[str, str] = field(default_factory=dict)
64
+
65
+ def route_for(self, path: str) -> str:
66
+ """The route for a repo-relative path: the longest matching prefix, else the repo's."""
67
+ return self.named_route(path) or self.route
68
+
69
+ def named_route(self, path: str) -> str | None:
70
+ """The route `routes` names for a path (the longest matching prefix), or None."""
71
+ best = ""
72
+ for prefix in self.routes:
73
+ if (path == prefix or path.startswith(prefix.rstrip("/") + "/")) and len(prefix) > len(best):
74
+ best = prefix
75
+ return self.routes[best] if best else None
76
+
77
+
78
+ @dataclass
79
+ class Config:
80
+ url: str
81
+ api_key_command: list[str]
82
+ admin_key_command: list[str] | None
83
+ pin_ip_command: list[str] | None
84
+ listen: str
85
+ webhook_url: str
86
+ secret_command: list[str] | None
87
+ poll_seconds: int
88
+ quiet_seconds: int
89
+ cap_seconds: int
90
+ notify_command: list[str] | None
91
+ notify_cwd: Path | None
92
+ mcp_url: str | None
93
+ author_command: list[str] | None
94
+ repos: list[Repo]
95
+ state: Path
96
+
97
+ def run(self, argv: list[str]) -> str:
98
+ return subprocess.check_output(argv, text=True).strip()
99
+
100
+ def api_key(self) -> str:
101
+ return self.run(self.api_key_command)
102
+
103
+ def secret(self) -> str | None:
104
+ return self.run(self.secret_command) if self.secret_command else None
105
+
106
+ def author(self) -> str | None:
107
+ """The route of whoever runs margin now (author_command), or None when there is no such
108
+ command or it does not answer. Never raises: publishing does not depend on it."""
109
+ if not self.author_command:
110
+ return None
111
+ try:
112
+ out = subprocess.run(self.author_command, capture_output=True, text=True, timeout=20,
113
+ cwd=str(self.notify_cwd) if self.notify_cwd else None)
114
+ except (OSError, subprocess.SubprocessError):
115
+ return None
116
+ name = (out.stdout or "").strip().splitlines()
117
+ return name[0].strip() if out.returncode == 0 and name and name[0].strip() else None
118
+
119
+ def repo(self, name: str) -> Repo:
120
+ for r in self.repos:
121
+ if r.name == name:
122
+ return r
123
+ raise KeyError(f"no repo named {name!r} in the config")
124
+
125
+
126
+ def load(path: Path = DEFAULT_PATH) -> Config:
127
+ raw = tomllib.loads(path.read_text())
128
+ o, i, b, n = (raw.get(k, {}) for k in ("outline", "intake", "batch", "notify"))
129
+ repos = [Repo(name=r["name"], path=Path(r["path"]).expanduser(),
130
+ include=r.get("include", ["**/*.md"]), exclude=r.get("exclude", []),
131
+ route=r.get("route", r["name"]), routes=r.get("routes", {}))
132
+ for r in raw.get("repo", [])]
133
+ names = [r.name for r in repos]
134
+ if len(names) != len(set(names)):
135
+ raise ValueError("two [[repo]] entries share a name")
136
+ return Config(
137
+ url=o["url"], api_key_command=o["api_key_command"],
138
+ admin_key_command=o.get("admin_key_command"), pin_ip_command=o.get("pin_ip_command"),
139
+ listen=i.get("listen", "127.0.0.1:8771"), webhook_url=i.get("webhook_url", ""),
140
+ secret_command=i.get("secret_command"), poll_seconds=int(i.get("poll_seconds", 600)),
141
+ quiet_seconds=int(b.get("quiet_seconds", 180)), cap_seconds=int(b.get("cap_seconds", 900)),
142
+ notify_command=n.get("command"),
143
+ notify_cwd=Path(n["cwd"]).expanduser() if n.get("cwd") else None,
144
+ mcp_url=o.get("mcp_url"), repos=repos, author_command=n.get("author_command"),
145
+ state=Path(raw.get("state", DEFAULT_STATE)).expanduser())
146
+
147
+
148
+ def locate(cfg: Config, file: Path) -> tuple[Repo, str, Path] | None:
149
+ """repo_of, and also a file in another git worktree of a configured repo: (repo, the file's
150
+ repo-relative path, the folder that path is relative to). Every chat works in its own worktree,
151
+ so a file there is found through git's common directory, which names the main checkout."""
152
+ hit = repo_of(cfg, file)
153
+ if hit:
154
+ return hit[0], hit[1], hit[0].path.resolve()
155
+ import subprocess
156
+ f = file.expanduser().resolve()
157
+ try:
158
+ r = subprocess.run(["git", "-C", str(f.parent), "rev-parse", "--path-format=absolute",
159
+ "--show-toplevel", "--git-common-dir"],
160
+ capture_output=True, text=True, timeout=10)
161
+ except (OSError, subprocess.SubprocessError):
162
+ return None
163
+ lines = r.stdout.splitlines()
164
+ if r.returncode != 0 or len(lines) < 2:
165
+ return None
166
+ top, main = Path(lines[0]).resolve(), Path(lines[1]).resolve().parent
167
+ best = None
168
+ for repo in cfg.repos:
169
+ root = repo.path.resolve()
170
+ if root != main and main not in root.parents:
171
+ continue # not this git repository
172
+ here = top / root.relative_to(main) # the configured folder, inside the worktree
173
+ if f == here or here in f.parents:
174
+ if best is None or len(here.parts) > len(best[2].parts):
175
+ best = (repo, f.relative_to(here).as_posix(), here)
176
+ return best
177
+
178
+
179
+ def repo_of(cfg: Config, file: Path) -> tuple[Repo, str] | None:
180
+ """The configured repo a file lives in, and its repo-relative path. The deepest repo wins."""
181
+ f = file.expanduser().resolve()
182
+ best = None
183
+ for r in cfg.repos:
184
+ root = r.path.resolve()
185
+ if f == root or root in f.parents:
186
+ if best is None or len(root.parts) > len(best[0].path.resolve().parts):
187
+ best = (r, f.relative_to(root).as_posix())
188
+ return best