Skip to content

Commit 499e07d

Browse files
committed
Add markdown directory sync plugin in Python
1 parent e37cbf4 commit 499e07d

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# shape-sync-markdowndir-python
2+
3+
A sync plugin for Shape CLI that stores briefs as individual markdown files with YAML frontmatter. Implemented in Python using only standard library dependencies.
4+
5+
## Requirements
6+
7+
- Python 3.7+
8+
9+
## Installation
10+
11+
```bash
12+
# Make executable
13+
chmod +x shape-sync-markdowndir-python
14+
15+
# Option 1: Add to PATH
16+
ln -s $(pwd)/shape-sync-markdowndir-python ~/.local/bin/
17+
18+
# Option 2: Add plugin directory to Shape config
19+
shape config set plugin_dirs "$(pwd)"
20+
```
21+
22+
## Configuration
23+
24+
Set the output directory via environment variable:
25+
26+
```bash
27+
export SHAPE_SYNC_MARKDOWN_DIR="./my-pitches"
28+
```
29+
30+
Default: `./pitches`
31+
32+
## Usage
33+
34+
The plugin syncs briefs to individual markdown files with YAML frontmatter.
35+
36+
```bash
37+
# Test the plugin
38+
shape sync test shape-sync-markdowndir-python
39+
40+
# Push briefs to markdown files
41+
shape sync push shape-sync-markdowndir-python
42+
43+
# Pull briefs from markdown files
44+
shape sync pull shape-sync-markdowndir-python
45+
46+
# Check sync status
47+
shape sync status shape-sync-markdowndir-python
48+
```
49+
50+
## File Format
51+
52+
Each brief is stored as a markdown file with YAML frontmatter:
53+
54+
```markdown
55+
---
56+
id: b-123
57+
title: "My Feature"
58+
status: proposed
59+
appetite: 2-week
60+
synced_at: 2024-01-15T10:30:00Z
61+
---
62+
63+
The body content of the brief goes here...
64+
```
65+
66+
Files are named using the pattern: `{id}-{slugified-title}.md`
67+
68+
Example: `b-123-my-feature.md`
69+
70+
## Protocol
71+
72+
### Manifest
73+
74+
```bash
75+
./shape-sync-markdowndir-python --manifest
76+
```
77+
78+
Returns:
79+
```json
80+
{"name":"shape-sync-markdowndir-python","version":"1.0.0","description":"Sync briefs to markdown directory (Python)","type":"sync","operations":["push","pull","status","test"]}
81+
```
82+
83+
### Operations
84+
85+
All operations receive JSON on stdin and return JSON on stdout.
86+
87+
#### test
88+
89+
Tests that the plugin is working.
90+
91+
```bash
92+
echo '{"operation":"test","params":{}}' | ./shape-sync-markdowndir-python
93+
```
94+
95+
#### push
96+
97+
Writes briefs as individual markdown files.
98+
99+
```bash
100+
echo '{"operation":"push","params":{"briefs":[{"id":"b-123","title":"Test Brief","status":"proposed","appetite":"2-week","body":"# Problem\n\nDescription here..."}]}}' | ./shape-sync-markdowndir-python
101+
```
102+
103+
#### pull
104+
105+
Reads briefs from markdown files in the output directory.
106+
107+
```bash
108+
echo '{"operation":"pull","params":{}}' | ./shape-sync-markdowndir-python
109+
```
110+
111+
#### status
112+
113+
Returns the count of synced briefs.
114+
115+
```bash
116+
echo '{"operation":"status","params":{}}' | ./shape-sync-markdowndir-python
117+
```
118+
119+
## Why Python?
120+
121+
This plugin showcases Python's strengths for scripting:
122+
123+
1. **Ubiquity**: Python is pre-installed on most systems
124+
2. **Readable**: Clear, explicit code that's easy to understand and modify
125+
3. **Zero dependencies**: Uses only Python standard library (`json`, `os`, `re`, `sys`, `datetime`)
126+
4. **Cross-platform**: Works on Windows, macOS, and Linux without changes
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import os
5+
import re
6+
import sys
7+
from datetime import datetime, timezone
8+
9+
OUTPUT_DIR = os.environ.get("SHAPE_SYNC_MARKDOWN_DIR", "./pitches")
10+
11+
MANIFEST = {
12+
"name": "shape-sync-markdowndir-python",
13+
"version": "1.0.0",
14+
"description": "Sync briefs to markdown directory (Python)",
15+
"type": "sync",
16+
"operations": ["push", "pull", "status", "test"],
17+
}
18+
19+
20+
def slugify(title):
21+
slug = title.lower()
22+
slug = re.sub(r"[^a-z0-9]+", "-", slug)
23+
slug = slug.strip("-")
24+
return slug
25+
26+
27+
def generate_markdown(brief):
28+
lines = ["---"]
29+
lines.append(f"id: {brief['id']}")
30+
lines.append(f"title: \"{brief['title']}\"")
31+
lines.append(f"status: {brief.get('status', 'proposed')}")
32+
if brief.get("appetite"):
33+
lines.append(f"appetite: {brief['appetite']}")
34+
synced_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
35+
lines.append(f"synced_at: {synced_at}")
36+
lines.append("---")
37+
lines.append("")
38+
lines.append(brief.get("body", ""))
39+
return "\n".join(lines)
40+
41+
42+
def parse_frontmatter(content):
43+
match = re.match(r"^---\n(.+?)\n---\n\n?(.*)", content, re.DOTALL)
44+
if not match:
45+
return None
46+
47+
frontmatter_text = match.group(1)
48+
body = match.group(2)
49+
50+
brief = {"body": body}
51+
52+
for line in frontmatter_text.split("\n"):
53+
if line.startswith("id:"):
54+
brief["id"] = line[3:].strip()
55+
elif line.startswith("title:"):
56+
title = line[6:].strip()
57+
title = title.strip('"')
58+
brief["title"] = title
59+
elif line.startswith("status:"):
60+
brief["status"] = line[7:].strip()
61+
elif line.startswith("appetite:"):
62+
brief["appetite"] = line[9:].strip()
63+
64+
return brief
65+
66+
67+
def handle_test():
68+
return {"success": True}
69+
70+
71+
def handle_push(params):
72+
briefs = params.get("briefs", [])
73+
74+
os.makedirs(OUTPUT_DIR, exist_ok=True)
75+
76+
mappings = []
77+
for brief in briefs:
78+
filename = f"{brief['id']}-{slugify(brief['title'])}.md"
79+
filepath = os.path.join(OUTPUT_DIR, filename)
80+
81+
with open(filepath, "w", encoding="utf-8") as f:
82+
f.write(generate_markdown(brief))
83+
84+
mappings.append({
85+
"local_id": brief["id"],
86+
"remote_id": filename,
87+
"entity_type": "brief",
88+
})
89+
90+
return {
91+
"success": True,
92+
"data": {
93+
"pushed": len(briefs),
94+
"pulled": 0,
95+
"conflicts": 0,
96+
"errors": [],
97+
"mappings": mappings,
98+
},
99+
}
100+
101+
102+
def handle_pull():
103+
if not os.path.isdir(OUTPUT_DIR):
104+
return {
105+
"success": True,
106+
"data": {
107+
"briefs": [],
108+
"tasks": [],
109+
"mappings": [],
110+
"pushed": 0,
111+
"pulled": 0,
112+
"conflicts": 0,
113+
"errors": [],
114+
},
115+
}
116+
117+
briefs = []
118+
for filename in os.listdir(OUTPUT_DIR):
119+
if not filename.endswith(".md"):
120+
continue
121+
122+
filepath = os.path.join(OUTPUT_DIR, filename)
123+
with open(filepath, encoding="utf-8") as f:
124+
content = f.read()
125+
126+
brief = parse_frontmatter(content)
127+
if brief:
128+
briefs.append(brief)
129+
130+
return {
131+
"success": True,
132+
"data": {
133+
"briefs": briefs,
134+
"tasks": [],
135+
"mappings": [],
136+
"pushed": 0,
137+
"pulled": len(briefs),
138+
"conflicts": 0,
139+
"errors": [],
140+
},
141+
}
142+
143+
144+
def handle_status():
145+
count = 0
146+
if os.path.isdir(OUTPUT_DIR):
147+
count = len([f for f in os.listdir(OUTPUT_DIR) if f.endswith(".md")])
148+
149+
return {
150+
"success": True,
151+
"data": {
152+
"mapped_briefs": count,
153+
"mapped_tasks": 0,
154+
},
155+
}
156+
157+
158+
def handle_request(request):
159+
operation = request.get("operation")
160+
params = request.get("params", {})
161+
162+
if operation == "test":
163+
return handle_test()
164+
elif operation == "push":
165+
return handle_push(params)
166+
elif operation == "pull":
167+
return handle_pull()
168+
elif operation == "status":
169+
return handle_status()
170+
else:
171+
return {"success": False, "error": f"Unknown operation: {operation}"}
172+
173+
174+
if __name__ == "__main__":
175+
if "--manifest" in sys.argv:
176+
print(json.dumps(MANIFEST))
177+
else:
178+
request = json.loads(sys.stdin.readline())
179+
response = handle_request(request)
180+
print(json.dumps(response))

0 commit comments

Comments
 (0)