margin 0.2.1__tar.gz

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 (39) hide show
  1. margin-0.2.1/LICENSE +21 -0
  2. margin-0.2.1/PKG-INFO +113 -0
  3. margin-0.2.1/README.md +95 -0
  4. margin-0.2.1/pyproject.toml +30 -0
  5. margin-0.2.1/setup.cfg +4 -0
  6. margin-0.2.1/src/margin/__init__.py +0 -0
  7. margin-0.2.1/src/margin/batch.py +41 -0
  8. margin-0.2.1/src/margin/claude.py +89 -0
  9. margin-0.2.1/src/margin/cli.py +294 -0
  10. margin-0.2.1/src/margin/config.py +188 -0
  11. margin-0.2.1/src/margin/fold.py +750 -0
  12. margin-0.2.1/src/margin/images.py +66 -0
  13. margin-0.2.1/src/margin/inbox.py +335 -0
  14. margin-0.2.1/src/margin/outline.py +282 -0
  15. margin-0.2.1/src/margin/patches.py +243 -0
  16. margin-0.2.1/src/margin/pinch_check.py +363 -0
  17. margin-0.2.1/src/margin/skills/margin/SKILL.md +95 -0
  18. margin-0.2.1/src/margin/store.py +186 -0
  19. margin-0.2.1/src/margin/sync.py +666 -0
  20. margin-0.2.1/src/margin.egg-info/PKG-INFO +113 -0
  21. margin-0.2.1/src/margin.egg-info/SOURCES.txt +37 -0
  22. margin-0.2.1/src/margin.egg-info/dependency_links.txt +1 -0
  23. margin-0.2.1/src/margin.egg-info/entry_points.txt +2 -0
  24. margin-0.2.1/src/margin.egg-info/requires.txt +3 -0
  25. margin-0.2.1/src/margin.egg-info/top_level.txt +1 -0
  26. margin-0.2.1/tests/test_batch.py +34 -0
  27. margin-0.2.1/tests/test_config.py +144 -0
  28. margin-0.2.1/tests/test_fold.py +345 -0
  29. margin-0.2.1/tests/test_images.py +121 -0
  30. margin-0.2.1/tests/test_inbox.py +128 -0
  31. margin-0.2.1/tests/test_outline_wait.py +78 -0
  32. margin-0.2.1/tests/test_own_edits.py +92 -0
  33. margin-0.2.1/tests/test_patches.py +192 -0
  34. margin-0.2.1/tests/test_pinch_check.py +114 -0
  35. margin-0.2.1/tests/test_sync.py +290 -0
  36. margin-0.2.1/tests/test_sync_replace.py +134 -0
  37. margin-0.2.1/tests/test_tree.py +98 -0
  38. margin-0.2.1/tests/test_verify.py +26 -0
  39. margin-0.2.1/tests/test_worktree_files.py +81 -0
margin-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Konstantinos Georgiou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
margin-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: margin
3
+ Version: 0.2.1
4
+ Summary: Publish a repo's markdown to a self-hosted Outline, keep comments anchored, and bring each new comment back to an AI agent in batches
5
+ Author: Konstantinos Georgiou
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/drkostas/margin
8
+ Keywords: outline,markdown,comments,claude,claude-code,agents,documentation
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Documentation
11
+ Classifier: Topic :: Utilities
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=8; extra == "test"
17
+ Dynamic: license-file
18
+
19
+ # margin
20
+
21
+ margin publishes the markdown files of your repositories to a self-hosted [Outline](https://github.com/outline/outline) wiki, and brings every comment you leave there back to an AI agent such as Claude Code. You read your docs in a browser or on your phone, select a sentence and comment on it, and the agent answers in the thread and changes the file.
22
+
23
+ Each repository becomes one Outline collection. Folders become parent documents, and a folder's `README.md` is that folder's page.
24
+
25
+ ## What it does
26
+
27
+ - **Publishes changes as small patches.** A comment in Outline is attached to a piece of text. Replacing a whole document through Outline's API removes every one of those attachments, so margin never does that. It works out the smallest changes, applies them one at a time, and checks each result against what it expected.
28
+ - **Publishes the pictures a file shows.** A local image referenced by a relative path (`![The settings page](shots/settings.png)`) is uploaded once as an attachment of its document, so everyone who can read the document sees it. The file keeps its relative path. A changed picture counts as a changed file.
29
+ - **Never settles a disagreement by "newest wins".** margin keeps both sides' text from the last sync. If only the file changed, it patches Outline. If someone edited the document in Outline, or both changed, it leaves both alone and reports the document.
30
+ - **Collects comments two ways.** Outline sends a signed webhook for each new comment. A catch-up poll every few minutes finds anything the webhook missed, for example while the machine was asleep. A comment that arrives both ways is recorded once.
31
+ - **Sends comments in batches.** A burst of comments becomes one message. A batch is delivered after a quiet period (3 minutes by default), never later than a cap after its first comment (15 minutes), and at once when a comment mentions the agent's user.
32
+ - **Keeps the agent out of its own inbox.** Comments written by the agent's user are never recorded, so its replies cannot wake it.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install margin
38
+ ```
39
+
40
+ margin needs Python 3.11 or newer and has no dependencies outside the standard library. You also need an Outline server that you can reach, with two users, yourself and the agent. [`examples/outline`](examples/outline) has a Docker Compose setup for Outline, Redis and a small sign-in provider.
41
+
42
+ ## Configure
43
+
44
+ margin reads `~/.config/margin/config.toml`. Secrets are never written in it. Each secret is the output of a command, for example a password manager or the macOS keychain.
45
+
46
+ ```toml
47
+ [outline]
48
+ url = "https://docs.example.com"
49
+ api_key_command = ["pass", "outline/agent-api-key"] # the agent's key, a normal member
50
+ admin_key_command = ["pass", "outline/admin-api-key"] # used only to create the webhook
51
+
52
+ [intake]
53
+ listen = "127.0.0.1:8771"
54
+ webhook_url = "http://host.docker.internal:8771/outline" # the address Outline posts to
55
+ secret_command = ["pass", "outline/webhook-secret"]
56
+ poll_seconds = 600
57
+
58
+ [batch]
59
+ quiet_seconds = 180
60
+ cap_seconds = 900
61
+
62
+ [notify]
63
+ command = ["my-notifier", "{route}"] # receives each batch as JSON on stdin; {text}, {repo} and {repo_path} are filled in
64
+
65
+ [[repo]]
66
+ name = "my-project" # the Outline collection
67
+ path = "~/code/my-project"
68
+ exclude = ["vendor/**"]
69
+ route = "my-project" # passed to the notifier as {route}
70
+ ```
71
+
72
+ When `[notify]` has no command, margin prints each batch.
73
+
74
+ Outline refuses to send webhooks to private addresses. If margin runs on the same machine as an Outline container, add the container's host gateway address to Outline's `ALLOWED_PRIVATE_IP_ADDRESSES` setting.
75
+
76
+ ## Use
77
+
78
+ ```bash
79
+ margin sync # publish what changed, in every configured repo
80
+ margin sync my-project --full
81
+ margin serve # webhook receiver, catch-up poll, batching and a sync every minute
82
+ margin status # documents that need attention, comments waiting
83
+ margin reply <comment-id> "Changed to five minutes, see the Lifetime section."
84
+ margin resolve <comment-id>
85
+ margin thread <comment-id>
86
+ margin link docs/plan.md # publish these files now and print a Markdown link to each
87
+ margin setup-claude # link the skill, register Outline's MCP, deny its destructive tools
88
+ ```
89
+
90
+ A batch looks like this.
91
+
92
+ ```text
93
+ 2 new comment(s) in Outline.
94
+
95
+ ### Cache design
96
+ my-project/docs/cache.md https://docs.example.com/doc/cache-design-pbS4AcgGK0
97
+ - [393bdced-...] Alex on "least recently used first":
98
+ Please also say what happens when memory is full.
99
+ - [819aea0e-...] Alex on "The cache holds entries":
100
+ Is ten minutes measured from write or from last read?
101
+ ```
102
+
103
+ The notifier also receives every comment as structured JSON, with the document's title, link and repository path.
104
+
105
+ ## Limits
106
+
107
+ - A comment attached to text that the agent then rewrites loses its attachment. The thread stays, with no highlighted text. margin keeps its patches small so that this happens only to text that really changed.
108
+ - Outline re-parses a whole list or quote when a patch has to cross into the next block. Comments inside that block lose their attachment the same way. New content at the end of a document is appended and touches nothing.
109
+ - Edits made in Outline are reported, not copied back to the file. That direction is planned.
110
+
111
+ ## Licence
112
+
113
+ MIT
margin-0.2.1/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # margin
2
+
3
+ margin publishes the markdown files of your repositories to a self-hosted [Outline](https://github.com/outline/outline) wiki, and brings every comment you leave there back to an AI agent such as Claude Code. You read your docs in a browser or on your phone, select a sentence and comment on it, and the agent answers in the thread and changes the file.
4
+
5
+ Each repository becomes one Outline collection. Folders become parent documents, and a folder's `README.md` is that folder's page.
6
+
7
+ ## What it does
8
+
9
+ - **Publishes changes as small patches.** A comment in Outline is attached to a piece of text. Replacing a whole document through Outline's API removes every one of those attachments, so margin never does that. It works out the smallest changes, applies them one at a time, and checks each result against what it expected.
10
+ - **Publishes the pictures a file shows.** A local image referenced by a relative path (`![The settings page](shots/settings.png)`) is uploaded once as an attachment of its document, so everyone who can read the document sees it. The file keeps its relative path. A changed picture counts as a changed file.
11
+ - **Never settles a disagreement by "newest wins".** margin keeps both sides' text from the last sync. If only the file changed, it patches Outline. If someone edited the document in Outline, or both changed, it leaves both alone and reports the document.
12
+ - **Collects comments two ways.** Outline sends a signed webhook for each new comment. A catch-up poll every few minutes finds anything the webhook missed, for example while the machine was asleep. A comment that arrives both ways is recorded once.
13
+ - **Sends comments in batches.** A burst of comments becomes one message. A batch is delivered after a quiet period (3 minutes by default), never later than a cap after its first comment (15 minutes), and at once when a comment mentions the agent's user.
14
+ - **Keeps the agent out of its own inbox.** Comments written by the agent's user are never recorded, so its replies cannot wake it.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install margin
20
+ ```
21
+
22
+ margin needs Python 3.11 or newer and has no dependencies outside the standard library. You also need an Outline server that you can reach, with two users, yourself and the agent. [`examples/outline`](examples/outline) has a Docker Compose setup for Outline, Redis and a small sign-in provider.
23
+
24
+ ## Configure
25
+
26
+ margin reads `~/.config/margin/config.toml`. Secrets are never written in it. Each secret is the output of a command, for example a password manager or the macOS keychain.
27
+
28
+ ```toml
29
+ [outline]
30
+ url = "https://docs.example.com"
31
+ api_key_command = ["pass", "outline/agent-api-key"] # the agent's key, a normal member
32
+ admin_key_command = ["pass", "outline/admin-api-key"] # used only to create the webhook
33
+
34
+ [intake]
35
+ listen = "127.0.0.1:8771"
36
+ webhook_url = "http://host.docker.internal:8771/outline" # the address Outline posts to
37
+ secret_command = ["pass", "outline/webhook-secret"]
38
+ poll_seconds = 600
39
+
40
+ [batch]
41
+ quiet_seconds = 180
42
+ cap_seconds = 900
43
+
44
+ [notify]
45
+ command = ["my-notifier", "{route}"] # receives each batch as JSON on stdin; {text}, {repo} and {repo_path} are filled in
46
+
47
+ [[repo]]
48
+ name = "my-project" # the Outline collection
49
+ path = "~/code/my-project"
50
+ exclude = ["vendor/**"]
51
+ route = "my-project" # passed to the notifier as {route}
52
+ ```
53
+
54
+ When `[notify]` has no command, margin prints each batch.
55
+
56
+ Outline refuses to send webhooks to private addresses. If margin runs on the same machine as an Outline container, add the container's host gateway address to Outline's `ALLOWED_PRIVATE_IP_ADDRESSES` setting.
57
+
58
+ ## Use
59
+
60
+ ```bash
61
+ margin sync # publish what changed, in every configured repo
62
+ margin sync my-project --full
63
+ margin serve # webhook receiver, catch-up poll, batching and a sync every minute
64
+ margin status # documents that need attention, comments waiting
65
+ margin reply <comment-id> "Changed to five minutes, see the Lifetime section."
66
+ margin resolve <comment-id>
67
+ margin thread <comment-id>
68
+ margin link docs/plan.md # publish these files now and print a Markdown link to each
69
+ margin setup-claude # link the skill, register Outline's MCP, deny its destructive tools
70
+ ```
71
+
72
+ A batch looks like this.
73
+
74
+ ```text
75
+ 2 new comment(s) in Outline.
76
+
77
+ ### Cache design
78
+ my-project/docs/cache.md https://docs.example.com/doc/cache-design-pbS4AcgGK0
79
+ - [393bdced-...] Alex on "least recently used first":
80
+ Please also say what happens when memory is full.
81
+ - [819aea0e-...] Alex on "The cache holds entries":
82
+ Is ten minutes measured from write or from last read?
83
+ ```
84
+
85
+ The notifier also receives every comment as structured JSON, with the document's title, link and repository path.
86
+
87
+ ## Limits
88
+
89
+ - A comment attached to text that the agent then rewrites loses its attachment. The thread stays, with no highlighted text. margin keeps its patches small so that this happens only to text that really changed.
90
+ - Outline re-parses a whole list or quote when a patch has to cross into the next block. Comments inside that block lose their attachment the same way. New content at the end of a document is appended and touches nothing.
91
+ - Edits made in Outline are reported, not copied back to the file. That direction is planned.
92
+
93
+ ## Licence
94
+
95
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "margin"
7
+ version = "0.2.1"
8
+ description = "Publish a repo's markdown to a self-hosted Outline, keep comments anchored, and bring each new comment back to an AI agent in batches"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Konstantinos Georgiou" }]
13
+ dependencies = []
14
+ keywords = ["outline", "markdown", "comments", "claude", "claude-code", "agents", "documentation"]
15
+ classifiers = ["Programming Language :: Python :: 3", "Topic :: Documentation", "Topic :: Utilities"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/drkostas/margin"
19
+
20
+ [project.scripts]
21
+ margin = "margin.cli:main"
22
+
23
+ [project.optional-dependencies]
24
+ test = ["pytest>=8"]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ margin = ["skills/*/SKILL.md"]
margin-0.2.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -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
@@ -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"]