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.
- margin-0.2.1/LICENSE +21 -0
- margin-0.2.1/PKG-INFO +113 -0
- margin-0.2.1/README.md +95 -0
- margin-0.2.1/pyproject.toml +30 -0
- margin-0.2.1/setup.cfg +4 -0
- margin-0.2.1/src/margin/__init__.py +0 -0
- margin-0.2.1/src/margin/batch.py +41 -0
- margin-0.2.1/src/margin/claude.py +89 -0
- margin-0.2.1/src/margin/cli.py +294 -0
- margin-0.2.1/src/margin/config.py +188 -0
- margin-0.2.1/src/margin/fold.py +750 -0
- margin-0.2.1/src/margin/images.py +66 -0
- margin-0.2.1/src/margin/inbox.py +335 -0
- margin-0.2.1/src/margin/outline.py +282 -0
- margin-0.2.1/src/margin/patches.py +243 -0
- margin-0.2.1/src/margin/pinch_check.py +363 -0
- margin-0.2.1/src/margin/skills/margin/SKILL.md +95 -0
- margin-0.2.1/src/margin/store.py +186 -0
- margin-0.2.1/src/margin/sync.py +666 -0
- margin-0.2.1/src/margin.egg-info/PKG-INFO +113 -0
- margin-0.2.1/src/margin.egg-info/SOURCES.txt +37 -0
- margin-0.2.1/src/margin.egg-info/dependency_links.txt +1 -0
- margin-0.2.1/src/margin.egg-info/entry_points.txt +2 -0
- margin-0.2.1/src/margin.egg-info/requires.txt +3 -0
- margin-0.2.1/src/margin.egg-info/top_level.txt +1 -0
- margin-0.2.1/tests/test_batch.py +34 -0
- margin-0.2.1/tests/test_config.py +144 -0
- margin-0.2.1/tests/test_fold.py +345 -0
- margin-0.2.1/tests/test_images.py +121 -0
- margin-0.2.1/tests/test_inbox.py +128 -0
- margin-0.2.1/tests/test_outline_wait.py +78 -0
- margin-0.2.1/tests/test_own_edits.py +92 -0
- margin-0.2.1/tests/test_patches.py +192 -0
- margin-0.2.1/tests/test_pinch_check.py +114 -0
- margin-0.2.1/tests/test_sync.py +290 -0
- margin-0.2.1/tests/test_sync_replace.py +134 -0
- margin-0.2.1/tests/test_tree.py +98 -0
- margin-0.2.1/tests/test_verify.py +26 -0
- 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 (``) 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 (``) 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
|
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"]
|