create-screenfix 0.1.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.
- package/bin/create-screenfix.js +40 -0
- package/lib/doctor.js +120 -0
- package/lib/init.js +165 -0
- package/lib/uninstall.js +67 -0
- package/lib/update.js +62 -0
- package/package.json +35 -0
- package/python/build/lib/screenfix/__init__.py +3 -0
- package/python/build/lib/screenfix/annotation_window.py +222 -0
- package/python/build/lib/screenfix/clipboard_watcher.py +164 -0
- package/python/build/lib/screenfix/config.py +66 -0
- package/python/build/lib/screenfix/daemon.py +204 -0
- package/python/build/lib/screenfix/mcp_server.py +318 -0
- package/python/build/lib/screenfix/task_tracker.py +129 -0
- package/python/pyproject.toml +24 -0
- package/python/requirements.txt +5 -0
- package/python/screenfix/__init__.py +3 -0
- package/python/screenfix/annotation_window.py +222 -0
- package/python/screenfix/clipboard_watcher.py +164 -0
- package/python/screenfix/config.py +66 -0
- package/python/screenfix/daemon.py +204 -0
- package/python/screenfix/mcp_server.py +318 -0
- package/python/screenfix/task_tracker.py +129 -0
- package/python/screenfix.egg-info/PKG-INFO +11 -0
- package/python/screenfix.egg-info/SOURCES.txt +14 -0
- package/python/screenfix.egg-info/dependency_links.txt +1 -0
- package/python/screenfix.egg-info/entry_points.txt +3 -0
- package/python/screenfix.egg-info/requires.txt +5 -0
- package/python/screenfix.egg-info/top_level.txt +1 -0
- package/templates/commands/screenfix-list-tasks.md +6 -0
- package/templates/commands/screenfix-start.md +8 -0
- package/templates/commands/screenfix-status.md +6 -0
- package/templates/commands/screenfix-stop.md +6 -0
- package/templates/commands/screenfix-tasks-next.md +14 -0
- package/templates/commands/screenfix-tasks-yolo.md +14 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""MCP server for ScreenFix."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mcp.server import Server
|
|
14
|
+
from mcp.server.stdio import stdio_server
|
|
15
|
+
from mcp.types import Tool, TextContent, ImageContent
|
|
16
|
+
|
|
17
|
+
from .config import config
|
|
18
|
+
from .task_tracker import get_tasks, get_pending_tasks, mark_task_complete
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
STATE_FILE = Path.home() / ".config" / "screenfix" / "state.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_daemon_state() -> dict:
|
|
25
|
+
"""Read the daemon state file."""
|
|
26
|
+
if not STATE_FILE.exists():
|
|
27
|
+
return {"running": False, "listening": False}
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
with open(STATE_FILE, "r") as f:
|
|
31
|
+
state = json.load(f)
|
|
32
|
+
|
|
33
|
+
pid = state.get("pid")
|
|
34
|
+
if pid:
|
|
35
|
+
try:
|
|
36
|
+
os.kill(pid, 0)
|
|
37
|
+
state["running"] = True
|
|
38
|
+
except OSError:
|
|
39
|
+
state["running"] = False
|
|
40
|
+
else:
|
|
41
|
+
state["running"] = False
|
|
42
|
+
|
|
43
|
+
return state
|
|
44
|
+
except (json.JSONDecodeError, IOError):
|
|
45
|
+
return {"running": False, "listening": False}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_screenshots() -> list[dict]:
|
|
49
|
+
"""Get list of screenshots."""
|
|
50
|
+
save_dir = Path(config.save_directory)
|
|
51
|
+
if not save_dir.exists():
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
screenshots = []
|
|
55
|
+
for f in save_dir.glob("*.png"):
|
|
56
|
+
screenshots.append({
|
|
57
|
+
"path": str(f),
|
|
58
|
+
"filename": f.name,
|
|
59
|
+
"modified": f.stat().st_mtime,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
screenshots.sort(key=lambda x: x["modified"], reverse=True)
|
|
63
|
+
return screenshots
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_last_screenshot() -> dict | None:
|
|
67
|
+
"""Get the most recent screenshot."""
|
|
68
|
+
screenshots = get_screenshots()
|
|
69
|
+
return screenshots[0] if screenshots else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def start_daemon() -> tuple[bool, str]:
|
|
73
|
+
"""Start the daemon as a background process."""
|
|
74
|
+
state = get_daemon_state()
|
|
75
|
+
if state.get("running"):
|
|
76
|
+
return True, "Daemon is already running"
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
log_dir = Path.home() / ".config" / "screenfix"
|
|
80
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
log_file = log_dir / "daemon.log"
|
|
82
|
+
|
|
83
|
+
with open(log_file, "a") as log:
|
|
84
|
+
subprocess.Popen(
|
|
85
|
+
[sys.executable, "-m", "screenfix.daemon"],
|
|
86
|
+
cwd=os.getcwd(),
|
|
87
|
+
stdout=log,
|
|
88
|
+
stderr=log,
|
|
89
|
+
start_new_session=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
time.sleep(0.5)
|
|
93
|
+
|
|
94
|
+
new_state = get_daemon_state()
|
|
95
|
+
if new_state.get("running"):
|
|
96
|
+
return True, f"Daemon started (PID: {new_state.get('pid')}). Use Cmd+Ctrl+Shift+4 to capture."
|
|
97
|
+
else:
|
|
98
|
+
return True, f"Daemon starting... Check logs at {log_file}"
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return False, f"Failed to start daemon: {e}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stop_daemon() -> tuple[bool, str]:
|
|
105
|
+
"""Stop the daemon."""
|
|
106
|
+
state = get_daemon_state()
|
|
107
|
+
if not state.get("running"):
|
|
108
|
+
return True, "Daemon is not running"
|
|
109
|
+
|
|
110
|
+
pid = state.get("pid")
|
|
111
|
+
if pid:
|
|
112
|
+
try:
|
|
113
|
+
os.kill(pid, 15)
|
|
114
|
+
return True, "Daemon stopped"
|
|
115
|
+
except OSError as e:
|
|
116
|
+
return False, f"Failed to stop daemon: {e}"
|
|
117
|
+
|
|
118
|
+
return False, "Could not find daemon PID"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_server() -> Server:
|
|
122
|
+
"""Create the MCP server."""
|
|
123
|
+
server = Server("screenfix")
|
|
124
|
+
|
|
125
|
+
@server.list_tools()
|
|
126
|
+
async def list_tools() -> list[Tool]:
|
|
127
|
+
return [
|
|
128
|
+
Tool(
|
|
129
|
+
name="start_daemon",
|
|
130
|
+
description="Start ScreenFix daemon. Use Cmd+Ctrl+Shift+4 to capture screenshots instantly.",
|
|
131
|
+
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
132
|
+
),
|
|
133
|
+
Tool(
|
|
134
|
+
name="stop_daemon",
|
|
135
|
+
description="Stop the ScreenFix daemon",
|
|
136
|
+
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
137
|
+
),
|
|
138
|
+
Tool(
|
|
139
|
+
name="get_status",
|
|
140
|
+
description="Get ScreenFix status",
|
|
141
|
+
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
142
|
+
),
|
|
143
|
+
Tool(
|
|
144
|
+
name="get_last_screenshot",
|
|
145
|
+
description="Get the most recent screenshot with its image and related task",
|
|
146
|
+
inputSchema={
|
|
147
|
+
"type": "object",
|
|
148
|
+
"properties": {
|
|
149
|
+
"include_image": {
|
|
150
|
+
"type": "boolean",
|
|
151
|
+
"description": "Include image data",
|
|
152
|
+
"default": True,
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
"required": [],
|
|
156
|
+
},
|
|
157
|
+
),
|
|
158
|
+
Tool(
|
|
159
|
+
name="list_screenshots",
|
|
160
|
+
description="List all screenshots",
|
|
161
|
+
inputSchema={
|
|
162
|
+
"type": "object",
|
|
163
|
+
"properties": {
|
|
164
|
+
"limit": {"type": "integer", "default": 10}
|
|
165
|
+
},
|
|
166
|
+
"required": [],
|
|
167
|
+
},
|
|
168
|
+
),
|
|
169
|
+
Tool(
|
|
170
|
+
name="get_tasks",
|
|
171
|
+
description="Get tasks from tasks.md",
|
|
172
|
+
inputSchema={
|
|
173
|
+
"type": "object",
|
|
174
|
+
"properties": {
|
|
175
|
+
"pending_only": {"type": "boolean", "default": False}
|
|
176
|
+
},
|
|
177
|
+
"required": [],
|
|
178
|
+
},
|
|
179
|
+
),
|
|
180
|
+
Tool(
|
|
181
|
+
name="complete_task",
|
|
182
|
+
description="Mark a task as complete",
|
|
183
|
+
inputSchema={
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": {
|
|
186
|
+
"task_text": {"type": "string", "description": "Task text to mark complete"}
|
|
187
|
+
},
|
|
188
|
+
"required": ["task_text"],
|
|
189
|
+
},
|
|
190
|
+
),
|
|
191
|
+
Tool(
|
|
192
|
+
name="read_screenshot",
|
|
193
|
+
description="Read a specific screenshot by path",
|
|
194
|
+
inputSchema={
|
|
195
|
+
"type": "object",
|
|
196
|
+
"properties": {
|
|
197
|
+
"path": {"type": "string", "description": "Path to screenshot"}
|
|
198
|
+
},
|
|
199
|
+
"required": ["path"],
|
|
200
|
+
},
|
|
201
|
+
),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
@server.call_tool()
|
|
205
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent | ImageContent]:
|
|
206
|
+
|
|
207
|
+
if name == "start_daemon":
|
|
208
|
+
success, message = start_daemon()
|
|
209
|
+
return [TextContent(type="text", text=message)]
|
|
210
|
+
|
|
211
|
+
elif name == "stop_daemon":
|
|
212
|
+
success, message = stop_daemon()
|
|
213
|
+
return [TextContent(type="text", text=message)]
|
|
214
|
+
|
|
215
|
+
elif name == "get_status":
|
|
216
|
+
state = get_daemon_state()
|
|
217
|
+
screenshots = get_screenshots()
|
|
218
|
+
tasks = get_pending_tasks()
|
|
219
|
+
|
|
220
|
+
status = "Running" if state.get("running") else "Not running"
|
|
221
|
+
text = f"""ScreenFix Status:
|
|
222
|
+
- Daemon: {status}
|
|
223
|
+
- Screenshots: {len(screenshots)}
|
|
224
|
+
- Pending tasks: {len(tasks)}
|
|
225
|
+
|
|
226
|
+
Use Cmd+Ctrl+Shift+4 to capture (instant, no delay)"""
|
|
227
|
+
return [TextContent(type="text", text=text)]
|
|
228
|
+
|
|
229
|
+
elif name == "get_last_screenshot":
|
|
230
|
+
include_image = arguments.get("include_image", True)
|
|
231
|
+
screenshot = get_last_screenshot()
|
|
232
|
+
|
|
233
|
+
if not screenshot:
|
|
234
|
+
return [TextContent(type="text", text="No screenshots found. Use Cmd+Ctrl+Shift+4 to capture.")]
|
|
235
|
+
|
|
236
|
+
result = [TextContent(type="text", text=f"Screenshot: {screenshot['path']}")]
|
|
237
|
+
|
|
238
|
+
if include_image and os.path.exists(screenshot["path"]):
|
|
239
|
+
with open(screenshot["path"], "rb") as f:
|
|
240
|
+
image_data = base64.standard_b64encode(f.read()).decode("utf-8")
|
|
241
|
+
result.append(ImageContent(type="image", data=image_data, mimeType="image/png"))
|
|
242
|
+
|
|
243
|
+
tasks = get_tasks()
|
|
244
|
+
for task in tasks:
|
|
245
|
+
if task.get("screenshot") == screenshot["path"]:
|
|
246
|
+
result.append(TextContent(
|
|
247
|
+
type="text",
|
|
248
|
+
text=f"\nTask: {task['text']}\nStatus: {'Done' if task['completed'] else 'Pending'}"
|
|
249
|
+
))
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
elif name == "list_screenshots":
|
|
255
|
+
limit = arguments.get("limit", 10)
|
|
256
|
+
screenshots = get_screenshots()[:limit]
|
|
257
|
+
|
|
258
|
+
if not screenshots:
|
|
259
|
+
return [TextContent(type="text", text="No screenshots found.")]
|
|
260
|
+
|
|
261
|
+
lines = ["Screenshots:"]
|
|
262
|
+
for i, s in enumerate(screenshots, 1):
|
|
263
|
+
lines.append(f"{i}. {s['filename']}")
|
|
264
|
+
|
|
265
|
+
return [TextContent(type="text", text="\n".join(lines))]
|
|
266
|
+
|
|
267
|
+
elif name == "get_tasks":
|
|
268
|
+
pending_only = arguments.get("pending_only", False)
|
|
269
|
+
tasks = get_pending_tasks() if pending_only else get_tasks()
|
|
270
|
+
|
|
271
|
+
if not tasks:
|
|
272
|
+
return [TextContent(type="text", text="No tasks found.")]
|
|
273
|
+
|
|
274
|
+
lines = ["Tasks:"]
|
|
275
|
+
for i, task in enumerate(tasks, 1):
|
|
276
|
+
status = "[x]" if task["completed"] else "[ ]"
|
|
277
|
+
lines.append(f"{i}. {status} {task['text']}")
|
|
278
|
+
|
|
279
|
+
return [TextContent(type="text", text="\n".join(lines))]
|
|
280
|
+
|
|
281
|
+
elif name == "complete_task":
|
|
282
|
+
task_text = arguments.get("task_text", "")
|
|
283
|
+
if mark_task_complete(task_text):
|
|
284
|
+
return [TextContent(type="text", text=f"Completed: {task_text}")]
|
|
285
|
+
return [TextContent(type="text", text=f"Task not found: {task_text}")]
|
|
286
|
+
|
|
287
|
+
elif name == "read_screenshot":
|
|
288
|
+
path = arguments.get("path", "")
|
|
289
|
+
if not path or not os.path.exists(path):
|
|
290
|
+
return [TextContent(type="text", text=f"Not found: {path}")]
|
|
291
|
+
|
|
292
|
+
with open(path, "rb") as f:
|
|
293
|
+
image_data = base64.standard_b64encode(f.read()).decode("utf-8")
|
|
294
|
+
|
|
295
|
+
return [
|
|
296
|
+
TextContent(type="text", text=f"Screenshot: {path}"),
|
|
297
|
+
ImageContent(type="image", data=image_data, mimeType="image/png"),
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
301
|
+
|
|
302
|
+
return server
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def run_server():
|
|
306
|
+
"""Run the MCP server."""
|
|
307
|
+
server = create_server()
|
|
308
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
309
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def main():
|
|
313
|
+
"""Main entry point."""
|
|
314
|
+
asyncio.run(run_server())
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
main()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Task tracker for managing tasks.md file."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from .config import config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_task(instruction: str, screenshot_path: Optional[str] = None) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Add a task to the tasks.md file.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
instruction: Task instruction text
|
|
16
|
+
screenshot_path: Optional path to related screenshot
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
The formatted task entry that was added
|
|
20
|
+
"""
|
|
21
|
+
tasks_path = Path(config.tasks_file)
|
|
22
|
+
|
|
23
|
+
# Create parent directory if needed
|
|
24
|
+
tasks_path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
# Create file with header if it doesn't exist
|
|
27
|
+
if not tasks_path.exists():
|
|
28
|
+
with open(tasks_path, "w") as f:
|
|
29
|
+
f.write("# Tasks for Claude Code\n\n")
|
|
30
|
+
|
|
31
|
+
# Format the task entry
|
|
32
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
33
|
+
|
|
34
|
+
# Build task lines
|
|
35
|
+
task_lines = [f"- [ ] {instruction.strip()}"]
|
|
36
|
+
|
|
37
|
+
# Add screenshot reference if provided
|
|
38
|
+
if screenshot_path:
|
|
39
|
+
task_lines.append(f" - Screenshot: `{screenshot_path}`")
|
|
40
|
+
|
|
41
|
+
task_lines.append(f" - Added: {timestamp}")
|
|
42
|
+
task_lines.append("") # Blank line after entry
|
|
43
|
+
|
|
44
|
+
task_entry = "\n".join(task_lines) + "\n"
|
|
45
|
+
|
|
46
|
+
# Append to file
|
|
47
|
+
with open(tasks_path, "a") as f:
|
|
48
|
+
f.write(task_entry)
|
|
49
|
+
|
|
50
|
+
return task_entry
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_tasks() -> list[dict]:
|
|
54
|
+
"""
|
|
55
|
+
Read all tasks from tasks.md.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of task dictionaries with keys: text, completed, screenshot, added
|
|
59
|
+
"""
|
|
60
|
+
tasks_path = Path(config.tasks_file)
|
|
61
|
+
|
|
62
|
+
if not tasks_path.exists():
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
tasks = []
|
|
66
|
+
current_task = None
|
|
67
|
+
|
|
68
|
+
with open(tasks_path, "r") as f:
|
|
69
|
+
for line in f:
|
|
70
|
+
# Check for task line
|
|
71
|
+
if line.startswith("- [ ] ") or line.startswith("- [x] "):
|
|
72
|
+
if current_task:
|
|
73
|
+
tasks.append(current_task)
|
|
74
|
+
completed = line.startswith("- [x] ")
|
|
75
|
+
current_task = {
|
|
76
|
+
"text": line[6:].strip(),
|
|
77
|
+
"completed": completed,
|
|
78
|
+
"screenshot": None,
|
|
79
|
+
"added": None,
|
|
80
|
+
}
|
|
81
|
+
elif current_task and line.strip().startswith("- Screenshot:"):
|
|
82
|
+
# Extract screenshot path
|
|
83
|
+
path = line.strip()[14:].strip().strip("`")
|
|
84
|
+
current_task["screenshot"] = path
|
|
85
|
+
elif current_task and line.strip().startswith("- Added:"):
|
|
86
|
+
# Extract timestamp
|
|
87
|
+
current_task["added"] = line.strip()[9:].strip()
|
|
88
|
+
|
|
89
|
+
# Don't forget the last task
|
|
90
|
+
if current_task:
|
|
91
|
+
tasks.append(current_task)
|
|
92
|
+
|
|
93
|
+
return tasks
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_pending_tasks() -> list[dict]:
|
|
97
|
+
"""Get only incomplete tasks."""
|
|
98
|
+
return [t for t in get_tasks() if not t["completed"]]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def mark_task_complete(task_text: str) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Mark a task as complete by its text.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
task_text: The task text to find and mark complete
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if task was found and marked, False otherwise
|
|
110
|
+
"""
|
|
111
|
+
tasks_path = Path(config.tasks_file)
|
|
112
|
+
|
|
113
|
+
if not tasks_path.exists():
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
with open(tasks_path, "r") as f:
|
|
117
|
+
content = f.read()
|
|
118
|
+
|
|
119
|
+
# Find and replace the checkbox
|
|
120
|
+
search = f"- [ ] {task_text}"
|
|
121
|
+
replace = f"- [x] {task_text}"
|
|
122
|
+
|
|
123
|
+
if search in content:
|
|
124
|
+
content = content.replace(search, replace, 1)
|
|
125
|
+
with open(tasks_path, "w") as f:
|
|
126
|
+
f.write(content)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
return False
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "screenfix"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Screenshot capture tool for Claude Code via MCP"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"mcp>=1.0.0",
|
|
13
|
+
"pyobjc-core>=10.0",
|
|
14
|
+
"pyobjc-framework-Cocoa>=10.0",
|
|
15
|
+
"pyobjc-framework-Quartz>=10.0",
|
|
16
|
+
"Pillow>=10.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
screenfix-daemon = "screenfix.daemon:main"
|
|
21
|
+
screenfix-mcp = "screenfix.mcp_server:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["."]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Annotation window for adding instructions to screenshots."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from AppKit import (
|
|
9
|
+
NSWindow,
|
|
10
|
+
NSWindowStyleMaskTitled,
|
|
11
|
+
NSWindowStyleMaskClosable,
|
|
12
|
+
NSWindowStyleMaskNonactivatingPanel,
|
|
13
|
+
NSBackingStoreBuffered,
|
|
14
|
+
NSImageView,
|
|
15
|
+
NSImage,
|
|
16
|
+
NSScrollView,
|
|
17
|
+
NSTextView,
|
|
18
|
+
NSButton,
|
|
19
|
+
NSBezelStyleRounded,
|
|
20
|
+
NSTextField,
|
|
21
|
+
NSFont,
|
|
22
|
+
NSApplication,
|
|
23
|
+
NSImageScaleProportionallyDown,
|
|
24
|
+
NSPanel,
|
|
25
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces,
|
|
26
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary,
|
|
27
|
+
)
|
|
28
|
+
from Quartz import kCGMaximumWindowLevelKey, CGWindowLevelForKey
|
|
29
|
+
from Foundation import NSMakeRect, NSObject
|
|
30
|
+
import objc
|
|
31
|
+
|
|
32
|
+
from .config import config
|
|
33
|
+
from .task_tracker import add_task
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_current_controller = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_screenshot(temp_path: str) -> str:
|
|
40
|
+
"""Save screenshot from temp location to final location."""
|
|
41
|
+
save_dir = Path(config.save_directory)
|
|
42
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
45
|
+
filename = f"screenshot_{timestamp}.png"
|
|
46
|
+
final_path = save_dir / filename
|
|
47
|
+
|
|
48
|
+
shutil.move(temp_path, final_path)
|
|
49
|
+
return str(final_path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cleanup_temp_file(temp_path: str):
|
|
53
|
+
"""Remove temporary file."""
|
|
54
|
+
try:
|
|
55
|
+
if os.path.exists(temp_path):
|
|
56
|
+
os.remove(temp_path)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AnnotationWindowDelegate(NSObject):
|
|
62
|
+
"""Delegate to handle window events."""
|
|
63
|
+
|
|
64
|
+
def initWithController_(self, controller):
|
|
65
|
+
self = objc.super(AnnotationWindowDelegate, self).init()
|
|
66
|
+
if self is None:
|
|
67
|
+
return None
|
|
68
|
+
self.controller = controller
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def windowWillClose_(self, notification):
|
|
72
|
+
"""Called when window is about to close."""
|
|
73
|
+
global _current_controller
|
|
74
|
+
_current_controller = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AnnotationWindowController(NSObject):
|
|
78
|
+
"""Controller for the annotation window."""
|
|
79
|
+
|
|
80
|
+
def initWithImagePath_(self, image_path):
|
|
81
|
+
self = objc.super(AnnotationWindowController, self).init()
|
|
82
|
+
if self is None:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
self.image_path = image_path
|
|
86
|
+
self.window = None
|
|
87
|
+
self.text_view = None
|
|
88
|
+
self.delegate = None
|
|
89
|
+
|
|
90
|
+
self._create_window()
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def _create_window(self):
|
|
94
|
+
"""Create the annotation window."""
|
|
95
|
+
image = NSImage.alloc().initWithContentsOfFile_(self.image_path)
|
|
96
|
+
if not image:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
img_size = image.size()
|
|
100
|
+
|
|
101
|
+
max_display_width = 400
|
|
102
|
+
max_display_height = 300
|
|
103
|
+
|
|
104
|
+
width_scale = min(1.0, max_display_width / img_size.width) if img_size.width > 0 else 1.0
|
|
105
|
+
height_scale = min(1.0, max_display_height / img_size.height) if img_size.height > 0 else 1.0
|
|
106
|
+
scale = min(width_scale, height_scale)
|
|
107
|
+
|
|
108
|
+
display_width = int(img_size.width * scale)
|
|
109
|
+
display_height = int(img_size.height * scale)
|
|
110
|
+
|
|
111
|
+
padding = 20
|
|
112
|
+
window_width = max(display_width + padding * 2, 450)
|
|
113
|
+
text_area_height = 80
|
|
114
|
+
button_area_height = 50
|
|
115
|
+
label_height = 25
|
|
116
|
+
window_height = display_height + text_area_height + button_area_height + label_height + padding * 3
|
|
117
|
+
|
|
118
|
+
style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskNonactivatingPanel
|
|
119
|
+
self.window = NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
120
|
+
NSMakeRect(200, 200, window_width, window_height),
|
|
121
|
+
style,
|
|
122
|
+
NSBackingStoreBuffered,
|
|
123
|
+
False,
|
|
124
|
+
)
|
|
125
|
+
self.window.setTitle_("ScreenFix - Add Instructions")
|
|
126
|
+
|
|
127
|
+
self.window.setFloatingPanel_(True)
|
|
128
|
+
self.window.setHidesOnDeactivate_(False)
|
|
129
|
+
|
|
130
|
+
max_level = CGWindowLevelForKey(kCGMaximumWindowLevelKey)
|
|
131
|
+
self.window.setLevel_(max_level - 1)
|
|
132
|
+
|
|
133
|
+
self.window.setCollectionBehavior_(
|
|
134
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces |
|
|
135
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
self.delegate = AnnotationWindowDelegate.alloc().initWithController_(self)
|
|
139
|
+
self.window.setDelegate_(self.delegate)
|
|
140
|
+
|
|
141
|
+
content = self.window.contentView()
|
|
142
|
+
y_pos = padding
|
|
143
|
+
|
|
144
|
+
# Cancel button
|
|
145
|
+
cancel_btn = NSButton.alloc().initWithFrame_(NSMakeRect(padding, y_pos, 100, 32))
|
|
146
|
+
cancel_btn.setTitle_("Cancel")
|
|
147
|
+
cancel_btn.setBezelStyle_(NSBezelStyleRounded)
|
|
148
|
+
cancel_btn.setTarget_(self)
|
|
149
|
+
cancel_btn.setAction_(objc.selector(self.cancel_, signature=b"v@:@"))
|
|
150
|
+
content.addSubview_(cancel_btn)
|
|
151
|
+
|
|
152
|
+
# Save button
|
|
153
|
+
save_btn = NSButton.alloc().initWithFrame_(NSMakeRect(window_width - padding - 100, y_pos, 100, 32))
|
|
154
|
+
save_btn.setTitle_("Save")
|
|
155
|
+
save_btn.setBezelStyle_(NSBezelStyleRounded)
|
|
156
|
+
save_btn.setTarget_(self)
|
|
157
|
+
save_btn.setAction_(objc.selector(self.save_, signature=b"v@:@"))
|
|
158
|
+
save_btn.setKeyEquivalent_("\r")
|
|
159
|
+
content.addSubview_(save_btn)
|
|
160
|
+
|
|
161
|
+
y_pos += button_area_height
|
|
162
|
+
|
|
163
|
+
# Text view
|
|
164
|
+
scroll_frame = NSMakeRect(padding, y_pos, window_width - padding * 2, text_area_height)
|
|
165
|
+
scroll_view = NSScrollView.alloc().initWithFrame_(scroll_frame)
|
|
166
|
+
scroll_view.setHasVerticalScroller_(True)
|
|
167
|
+
scroll_view.setBorderType_(1)
|
|
168
|
+
|
|
169
|
+
text_frame = NSMakeRect(0, 0, scroll_frame.size.width - 4, scroll_frame.size.height)
|
|
170
|
+
self.text_view = NSTextView.alloc().initWithFrame_(text_frame)
|
|
171
|
+
self.text_view.setFont_(NSFont.systemFontOfSize_(13))
|
|
172
|
+
scroll_view.setDocumentView_(self.text_view)
|
|
173
|
+
content.addSubview_(scroll_view)
|
|
174
|
+
|
|
175
|
+
y_pos += text_area_height + 5
|
|
176
|
+
|
|
177
|
+
# Label
|
|
178
|
+
label = NSTextField.labelWithString_("Instructions for Claude Code:")
|
|
179
|
+
label.setFrame_(NSMakeRect(padding, y_pos, 300, label_height))
|
|
180
|
+
content.addSubview_(label)
|
|
181
|
+
|
|
182
|
+
y_pos += label_height + 5
|
|
183
|
+
|
|
184
|
+
# Image view
|
|
185
|
+
image_view = NSImageView.alloc().initWithFrame_(NSMakeRect(padding, y_pos, display_width, display_height))
|
|
186
|
+
image_view.setImage_(image)
|
|
187
|
+
image_view.setImageScaling_(NSImageScaleProportionallyDown)
|
|
188
|
+
content.addSubview_(image_view)
|
|
189
|
+
|
|
190
|
+
def show(self):
|
|
191
|
+
"""Display the window on top of fullscreen apps."""
|
|
192
|
+
self.window.center()
|
|
193
|
+
self.window.orderFrontRegardless()
|
|
194
|
+
self.window.makeKeyWindow()
|
|
195
|
+
self.window.makeFirstResponder_(self.text_view)
|
|
196
|
+
|
|
197
|
+
def cancel_(self, sender):
|
|
198
|
+
"""Handle cancel button click."""
|
|
199
|
+
cleanup_temp_file(self.image_path)
|
|
200
|
+
self.window.close()
|
|
201
|
+
|
|
202
|
+
def save_(self, sender):
|
|
203
|
+
"""Handle save button click."""
|
|
204
|
+
instructions = self.text_view.string()
|
|
205
|
+
saved_path = save_screenshot(self.image_path)
|
|
206
|
+
|
|
207
|
+
if instructions and instructions.strip():
|
|
208
|
+
add_task(instructions.strip(), saved_path)
|
|
209
|
+
|
|
210
|
+
self.window.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def show_annotation_window(image_path: str) -> None:
|
|
214
|
+
"""Display the annotation window for a screenshot."""
|
|
215
|
+
global _current_controller
|
|
216
|
+
|
|
217
|
+
if _current_controller and _current_controller.window:
|
|
218
|
+
_current_controller.window.close()
|
|
219
|
+
|
|
220
|
+
_current_controller = AnnotationWindowController.alloc().initWithImagePath_(image_path)
|
|
221
|
+
if _current_controller:
|
|
222
|
+
_current_controller.show()
|