flowly-code 1.0.0__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.
Files changed (86) hide show
  1. flowly_code/__init__.py +30 -0
  2. flowly_code/__main__.py +8 -0
  3. flowly_code/activity/__init__.py +1 -0
  4. flowly_code/activity/bus.py +91 -0
  5. flowly_code/activity/events.py +40 -0
  6. flowly_code/agent/__init__.py +8 -0
  7. flowly_code/agent/context.py +485 -0
  8. flowly_code/agent/loop.py +1349 -0
  9. flowly_code/agent/memory.py +109 -0
  10. flowly_code/agent/skills.py +259 -0
  11. flowly_code/agent/subagent.py +249 -0
  12. flowly_code/agent/tools/__init__.py +6 -0
  13. flowly_code/agent/tools/base.py +55 -0
  14. flowly_code/agent/tools/delegate.py +194 -0
  15. flowly_code/agent/tools/dispatch.py +840 -0
  16. flowly_code/agent/tools/docker.py +609 -0
  17. flowly_code/agent/tools/filesystem.py +280 -0
  18. flowly_code/agent/tools/mcp.py +85 -0
  19. flowly_code/agent/tools/message.py +235 -0
  20. flowly_code/agent/tools/registry.py +257 -0
  21. flowly_code/agent/tools/screenshot.py +444 -0
  22. flowly_code/agent/tools/shell.py +166 -0
  23. flowly_code/agent/tools/spawn.py +65 -0
  24. flowly_code/agent/tools/system.py +917 -0
  25. flowly_code/agent/tools/trello.py +420 -0
  26. flowly_code/agent/tools/web.py +139 -0
  27. flowly_code/agent/tools/x.py +399 -0
  28. flowly_code/bus/__init__.py +6 -0
  29. flowly_code/bus/events.py +37 -0
  30. flowly_code/bus/queue.py +81 -0
  31. flowly_code/channels/__init__.py +6 -0
  32. flowly_code/channels/base.py +121 -0
  33. flowly_code/channels/manager.py +135 -0
  34. flowly_code/channels/telegram.py +1132 -0
  35. flowly_code/cli/__init__.py +1 -0
  36. flowly_code/cli/commands.py +1831 -0
  37. flowly_code/cli/setup.py +1356 -0
  38. flowly_code/compaction/__init__.py +39 -0
  39. flowly_code/compaction/estimator.py +88 -0
  40. flowly_code/compaction/pruning.py +223 -0
  41. flowly_code/compaction/service.py +297 -0
  42. flowly_code/compaction/summarizer.py +384 -0
  43. flowly_code/compaction/types.py +71 -0
  44. flowly_code/config/__init__.py +6 -0
  45. flowly_code/config/loader.py +102 -0
  46. flowly_code/config/schema.py +324 -0
  47. flowly_code/exec/__init__.py +39 -0
  48. flowly_code/exec/approvals.py +288 -0
  49. flowly_code/exec/executor.py +184 -0
  50. flowly_code/exec/safety.py +247 -0
  51. flowly_code/exec/types.py +88 -0
  52. flowly_code/gateway/__init__.py +5 -0
  53. flowly_code/gateway/server.py +103 -0
  54. flowly_code/heartbeat/__init__.py +5 -0
  55. flowly_code/heartbeat/service.py +130 -0
  56. flowly_code/multiagent/README.md +248 -0
  57. flowly_code/multiagent/__init__.py +1 -0
  58. flowly_code/multiagent/invoke.py +210 -0
  59. flowly_code/multiagent/orchestrator.py +156 -0
  60. flowly_code/multiagent/router.py +156 -0
  61. flowly_code/multiagent/setup.py +171 -0
  62. flowly_code/pairing/__init__.py +21 -0
  63. flowly_code/pairing/store.py +343 -0
  64. flowly_code/providers/__init__.py +6 -0
  65. flowly_code/providers/base.py +69 -0
  66. flowly_code/providers/litellm_provider.py +178 -0
  67. flowly_code/providers/transcription.py +64 -0
  68. flowly_code/session/__init__.py +5 -0
  69. flowly_code/session/manager.py +249 -0
  70. flowly_code/skills/README.md +24 -0
  71. flowly_code/skills/compact/SKILL.md +27 -0
  72. flowly_code/skills/github/SKILL.md +48 -0
  73. flowly_code/skills/skill-creator/SKILL.md +371 -0
  74. flowly_code/skills/summarize/SKILL.md +67 -0
  75. flowly_code/skills/tmux/SKILL.md +121 -0
  76. flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
  77. flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
  78. flowly_code/skills/weather/SKILL.md +49 -0
  79. flowly_code/utils/__init__.py +5 -0
  80. flowly_code/utils/helpers.py +91 -0
  81. flowly_code-1.0.0.dist-info/METADATA +724 -0
  82. flowly_code-1.0.0.dist-info/RECORD +86 -0
  83. flowly_code-1.0.0.dist-info/WHEEL +4 -0
  84. flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
  85. flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
  86. flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
@@ -0,0 +1,257 @@
1
+ """Tool registry for dynamic tool management."""
2
+
3
+ from typing import Any
4
+
5
+ from flowly_code.agent.tools.base import Tool
6
+
7
+
8
+ def _extract_enum_values(schema: Any) -> list[Any] | None:
9
+ """Extract enum-like values from a JSON schema fragment."""
10
+ if not isinstance(schema, dict):
11
+ return None
12
+ if isinstance(schema.get("enum"), list):
13
+ return list(schema["enum"])
14
+ if "const" in schema:
15
+ return [schema["const"]]
16
+ variants = None
17
+ if isinstance(schema.get("anyOf"), list):
18
+ variants = schema["anyOf"]
19
+ elif isinstance(schema.get("oneOf"), list):
20
+ variants = schema["oneOf"]
21
+ elif isinstance(schema.get("allOf"), list):
22
+ variants = schema["allOf"]
23
+ if not variants:
24
+ return None
25
+ values: list[Any] = []
26
+ for variant in variants:
27
+ extracted = _extract_enum_values(variant)
28
+ if extracted:
29
+ values.extend(extracted)
30
+ return values or None
31
+
32
+
33
+ def _merge_property_schema(existing: Any, incoming: Any) -> Any:
34
+ """Merge two property schema fragments conservatively."""
35
+ if existing is None:
36
+ return incoming
37
+ if incoming is None:
38
+ return existing
39
+
40
+ existing_enum = _extract_enum_values(existing)
41
+ incoming_enum = _extract_enum_values(incoming)
42
+ if existing_enum or incoming_enum:
43
+ values = []
44
+ seen = set()
45
+ for value in [*(existing_enum or []), *(incoming_enum or [])]:
46
+ key = repr(value)
47
+ if key in seen:
48
+ continue
49
+ seen.add(key)
50
+ values.append(value)
51
+
52
+ merged: dict[str, Any] = {}
53
+ for source in (existing, incoming):
54
+ if isinstance(source, dict):
55
+ for key in ("title", "description", "default"):
56
+ if key not in merged and key in source:
57
+ merged[key] = source[key]
58
+ if values:
59
+ merged["enum"] = values
60
+ return merged
61
+
62
+ return existing
63
+
64
+
65
+ def _normalize_tool_parameters_schema(parameters: Any) -> dict[str, Any]:
66
+ """
67
+ Normalize tool schemas for provider compatibility.
68
+
69
+ Some providers reject top-level oneOf/anyOf/allOf in tool input schema.
70
+ We flatten top-level unions into a single object schema.
71
+ """
72
+ if not isinstance(parameters, dict):
73
+ return {"type": "object", "properties": {}, "additionalProperties": True}
74
+
75
+ has_top_union = any(
76
+ isinstance(parameters.get(key), list)
77
+ for key in ("anyOf", "oneOf", "allOf")
78
+ )
79
+
80
+ if not has_top_union:
81
+ # Ensure top-level object shape for function tools.
82
+ if "type" not in parameters and (
83
+ isinstance(parameters.get("properties"), dict)
84
+ or isinstance(parameters.get("required"), list)
85
+ ):
86
+ patched = dict(parameters)
87
+ patched["type"] = "object"
88
+ return patched
89
+ return parameters
90
+
91
+ variants: list[Any] = []
92
+ for key in ("anyOf", "oneOf", "allOf"):
93
+ raw = parameters.get(key)
94
+ if isinstance(raw, list):
95
+ variants.extend(raw)
96
+
97
+ merged_properties: dict[str, Any] = {}
98
+ required_counts: dict[str, int] = {}
99
+ object_variants = 0
100
+
101
+ for variant in variants:
102
+ if not isinstance(variant, dict):
103
+ continue
104
+ props = variant.get("properties")
105
+ if not isinstance(props, dict):
106
+ continue
107
+ object_variants += 1
108
+ for prop_key, prop_schema in props.items():
109
+ if prop_key not in merged_properties:
110
+ merged_properties[prop_key] = prop_schema
111
+ else:
112
+ merged_properties[prop_key] = _merge_property_schema(
113
+ merged_properties[prop_key],
114
+ prop_schema,
115
+ )
116
+
117
+ required = variant.get("required")
118
+ if isinstance(required, list):
119
+ for req_key in required:
120
+ if isinstance(req_key, str):
121
+ required_counts[req_key] = required_counts.get(req_key, 0) + 1
122
+
123
+ base_required = parameters.get("required")
124
+ merged_required: list[str] | None = None
125
+ if isinstance(base_required, list):
126
+ merged_required = [key for key in base_required if isinstance(key, str)]
127
+ elif object_variants > 0:
128
+ merged_required = [
129
+ key for key, count in required_counts.items()
130
+ if count == object_variants
131
+ ]
132
+
133
+ normalized: dict[str, Any] = {
134
+ "type": "object",
135
+ "properties": merged_properties if merged_properties else parameters.get("properties", {}),
136
+ "additionalProperties": parameters.get("additionalProperties", True),
137
+ }
138
+ if isinstance(parameters.get("title"), str):
139
+ normalized["title"] = parameters["title"]
140
+ if isinstance(parameters.get("description"), str):
141
+ normalized["description"] = parameters["description"]
142
+ if merged_required:
143
+ normalized["required"] = merged_required
144
+
145
+ return normalized
146
+
147
+
148
+ class ToolRegistry:
149
+ """
150
+ Registry for agent tools.
151
+
152
+ Allows dynamic registration and execution of tools.
153
+ """
154
+
155
+ def __init__(self):
156
+ self._tools: dict[str, Tool] = {}
157
+
158
+ def register(self, tool: Tool) -> None:
159
+ """Register a tool."""
160
+ self._tools[tool.name] = tool
161
+
162
+ def unregister(self, name: str) -> None:
163
+ """Unregister a tool by name."""
164
+ self._tools.pop(name, None)
165
+
166
+ def get(self, name: str) -> Tool | None:
167
+ """Get a tool by name."""
168
+ return self._tools.get(name)
169
+
170
+ def has(self, name: str) -> bool:
171
+ """Check if a tool is registered."""
172
+ return name in self._tools
173
+
174
+ def get_definitions(self) -> list[dict[str, Any]]:
175
+ """Get all tool definitions in OpenAI format."""
176
+ definitions = [tool.to_schema() for tool in self._tools.values()]
177
+ normalized: list[dict[str, Any]] = []
178
+ for definition in definitions:
179
+ fn = definition.get("function")
180
+ if isinstance(fn, dict):
181
+ fn = dict(fn)
182
+ fn["parameters"] = _normalize_tool_parameters_schema(fn.get("parameters"))
183
+ definition = dict(definition)
184
+ definition["function"] = fn
185
+ normalized.append(definition)
186
+ return normalized
187
+
188
+ def validate_tool_call(self, name: str, params: dict[str, Any]) -> str | None:
189
+ """Validate required params against normalized schema before execution."""
190
+ tool = self._tools.get(name)
191
+ if not tool:
192
+ return f"Error: Tool '{name}' not found"
193
+
194
+ if not isinstance(params, dict):
195
+ return f"Error: Invalid parameters for tool '{name}'"
196
+
197
+ schema = _normalize_tool_parameters_schema(tool.parameters)
198
+ required = schema.get("required")
199
+ if not isinstance(required, list):
200
+ return None
201
+
202
+ missing: list[str] = []
203
+ for key in required:
204
+ if not isinstance(key, str):
205
+ continue
206
+ if key not in params:
207
+ missing.append(key)
208
+ continue
209
+ value = params.get(key)
210
+ if value is None:
211
+ missing.append(key)
212
+ continue
213
+ if isinstance(value, str) and not value.strip():
214
+ missing.append(key)
215
+
216
+ if missing:
217
+ joined = ", ".join(sorted(set(missing)))
218
+ return f"Error: Missing required parameter(s) for '{name}': {joined}"
219
+ return None
220
+
221
+ async def execute(self, name: str, params: dict[str, Any]) -> str:
222
+ """
223
+ Execute a tool by name with given parameters.
224
+
225
+ Args:
226
+ name: Tool name.
227
+ params: Tool parameters.
228
+
229
+ Returns:
230
+ Tool execution result as string.
231
+
232
+ Raises:
233
+ KeyError: If tool not found.
234
+ """
235
+ tool = self._tools.get(name)
236
+ if not tool:
237
+ return f"Error: Tool '{name}' not found"
238
+
239
+ validation_error = self.validate_tool_call(name, params)
240
+ if validation_error:
241
+ return validation_error
242
+
243
+ try:
244
+ return await tool.execute(**params)
245
+ except Exception as e:
246
+ return f"Error executing {name}: {str(e)}"
247
+
248
+ @property
249
+ def tool_names(self) -> list[str]:
250
+ """Get list of registered tool names."""
251
+ return list(self._tools.keys())
252
+
253
+ def __len__(self) -> int:
254
+ return len(self._tools)
255
+
256
+ def __contains__(self, name: str) -> bool:
257
+ return name in self._tools
@@ -0,0 +1,444 @@
1
+ """Screenshot tool for capturing screen images."""
2
+
3
+ import json
4
+ import mimetypes
5
+ import platform
6
+ import subprocess
7
+ import shutil
8
+ import urllib.request
9
+ import urllib.error
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from loguru import logger
15
+
16
+ from flowly_code.agent.tools.base import Tool
17
+
18
+ # Electron desktop app API discovery file
19
+ _ELECTRON_API_FILE = Path.home() / ".flowly" / "electron-api.json"
20
+
21
+
22
+ class ScreenshotTool(Tool):
23
+ """
24
+ Tool to capture screenshots of the screen or specific windows.
25
+
26
+ Supports macOS, Linux (with gnome-screenshot or scrot), and Windows.
27
+ Screenshots are saved to ~/.flowly/screenshots/ directory.
28
+ """
29
+
30
+ # Supported image formats
31
+ SUPPORTED_FORMATS = {"png", "jpg", "jpeg", "gif", "tiff"}
32
+
33
+ # Maximum file size (10MB)
34
+ MAX_FILE_SIZE = 10 * 1024 * 1024
35
+
36
+ def __init__(self, screenshots_dir: Path | None = None):
37
+ """
38
+ Initialize the screenshot tool.
39
+
40
+ Args:
41
+ screenshots_dir: Custom directory for saving screenshots.
42
+ Defaults to ~/.flowly/screenshots/
43
+ """
44
+ self._screenshots_dir = screenshots_dir or (Path.home() / ".flowly" / "screenshots")
45
+ self._screenshots_dir.mkdir(parents=True, exist_ok=True)
46
+ self._platform = platform.system().lower()
47
+
48
+ @property
49
+ def name(self) -> str:
50
+ return "screenshot"
51
+
52
+ @property
53
+ def description(self) -> str:
54
+ return (
55
+ "Capture a screenshot of the entire screen or a specific display. "
56
+ "Returns the file path of the saved screenshot. "
57
+ "Use the 'message' tool with media_paths to send the screenshot to the user."
58
+ )
59
+
60
+ @property
61
+ def parameters(self) -> dict[str, Any]:
62
+ return {
63
+ "type": "object",
64
+ "properties": {
65
+ "display": {
66
+ "type": "integer",
67
+ "description": "Display number to capture (0 for main display). Default: 0"
68
+ },
69
+ "filename": {
70
+ "type": "string",
71
+ "description": "Optional custom filename (without extension). Default: timestamp-based name"
72
+ },
73
+ "format": {
74
+ "type": "string",
75
+ "enum": ["png", "jpg"],
76
+ "description": "Image format. Default: png"
77
+ }
78
+ },
79
+ "required": []
80
+ }
81
+
82
+ async def execute(
83
+ self,
84
+ display: int = 0,
85
+ filename: str | None = None,
86
+ format: str = "png",
87
+ **kwargs: Any
88
+ ) -> str:
89
+ """
90
+ Capture a screenshot.
91
+
92
+ Args:
93
+ display: Display number to capture (0 for main).
94
+ filename: Optional custom filename.
95
+ format: Image format (png or jpg).
96
+
97
+ Returns:
98
+ Success message with file path, or error message.
99
+ """
100
+ # Validate format
101
+ format = format.lower()
102
+ if format not in {"png", "jpg", "jpeg"}:
103
+ return f"Error: Unsupported format '{format}'. Use 'png' or 'jpg'."
104
+
105
+ # Normalize jpg/jpeg
106
+ if format == "jpeg":
107
+ format = "jpg"
108
+
109
+ # Generate filename
110
+ if filename:
111
+ # Sanitize filename
112
+ safe_filename = "".join(c for c in filename if c.isalnum() or c in "-_")
113
+ if not safe_filename:
114
+ safe_filename = "screenshot"
115
+ else:
116
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
117
+ safe_filename = f"screenshot-{timestamp}"
118
+
119
+ output_path = self._screenshots_dir / f"{safe_filename}.{format}"
120
+
121
+ # Avoid overwriting
122
+ counter = 1
123
+ while output_path.exists():
124
+ output_path = self._screenshots_dir / f"{safe_filename}-{counter}.{format}"
125
+ counter += 1
126
+
127
+ try:
128
+ # Platform-specific screenshot
129
+ if self._platform == "darwin":
130
+ result = await self._capture_macos(output_path, display)
131
+ elif self._platform == "linux":
132
+ result = await self._capture_linux(output_path, display)
133
+ elif self._platform == "windows":
134
+ result = await self._capture_windows(output_path, display)
135
+ else:
136
+ return f"Error: Unsupported platform '{self._platform}'"
137
+
138
+ if result is not None:
139
+ return result # Error message
140
+
141
+ # Verify file was created
142
+ if not output_path.exists():
143
+ return "Error: Screenshot file was not created"
144
+
145
+ # Check file size
146
+ file_size = output_path.stat().st_size
147
+ if file_size > self.MAX_FILE_SIZE:
148
+ output_path.unlink()
149
+ return f"Error: Screenshot too large ({file_size / 1024 / 1024:.1f}MB). Max: 10MB"
150
+
151
+ if file_size == 0:
152
+ output_path.unlink()
153
+ return "Error: Screenshot file is empty"
154
+
155
+ logger.info(f"Screenshot saved: {output_path} ({file_size / 1024:.1f}KB)")
156
+
157
+ return (
158
+ f"Screenshot saved successfully.\n"
159
+ f"Path: {output_path}\n"
160
+ f"Size: {file_size / 1024:.1f}KB\n\n"
161
+ f"To send this screenshot to the user, use the message tool with:\n"
162
+ f'message(content="Here is the screenshot", media_paths=["{output_path}"])'
163
+ )
164
+
165
+ except Exception as e:
166
+ logger.error(f"Screenshot failed: {e}")
167
+ return f"Error capturing screenshot: {str(e)}"
168
+
169
+ def _capture_via_electron_sync(
170
+ self, output_path: Path, display: int
171
+ ) -> str | None:
172
+ """
173
+ Try to capture screenshot via Electron desktop app's HTTP API.
174
+
175
+ The Electron app has macOS Screen Recording (TCC) permission and exposes
176
+ a localhost-only HTTP endpoint for screenshot capture with bearer token auth.
177
+
178
+ Returns None on success, error message on failure,
179
+ or "UNAVAILABLE" if Electron is not running.
180
+ """
181
+ if not _ELECTRON_API_FILE.exists():
182
+ return "UNAVAILABLE"
183
+
184
+ try:
185
+ api_data = json.loads(_ELECTRON_API_FILE.read_text())
186
+ port = int(api_data["port"])
187
+ token = str(api_data["token"])
188
+ except (ValueError, KeyError, json.JSONDecodeError, OSError):
189
+ return "UNAVAILABLE"
190
+
191
+ url = f"http://127.0.0.1:{port}/screenshot"
192
+ payload = json.dumps({
193
+ "display": display,
194
+ "format": output_path.suffix.lstrip("."),
195
+ "filename": output_path.stem,
196
+ }).encode()
197
+
198
+ req = urllib.request.Request(
199
+ url,
200
+ data=payload,
201
+ headers={
202
+ "Content-Type": "application/json",
203
+ "Authorization": f"Bearer {token}",
204
+ },
205
+ method="POST",
206
+ )
207
+
208
+ try:
209
+ with urllib.request.urlopen(req, timeout=20) as resp:
210
+ data = json.loads(resp.read())
211
+ if data.get("success"):
212
+ electron_path = Path(data.get("path", ""))
213
+ if electron_path.exists():
214
+ if electron_path != output_path:
215
+ electron_path.rename(output_path)
216
+ return None # Success
217
+ return "Error: Electron reported success but file not found"
218
+ return f"Error: Electron screenshot failed - {data.get('error', 'unknown')}"
219
+ except urllib.error.HTTPError as e:
220
+ if e.code == 401:
221
+ logger.warning("Electron screenshot auth failed (token mismatch)")
222
+ return "UNAVAILABLE"
223
+ logger.warning(f"Electron screenshot HTTP error: {e.code}")
224
+ return "UNAVAILABLE"
225
+ except (urllib.error.URLError, OSError):
226
+ return "UNAVAILABLE"
227
+ except Exception as e:
228
+ logger.warning(f"Electron screenshot delegation failed: {e}")
229
+ return "UNAVAILABLE"
230
+
231
+ async def _capture_macos(self, output_path: Path, display: int) -> str | None:
232
+ """
233
+ Capture screenshot on macOS.
234
+
235
+ Tries Electron desktop app delegation first (has TCC permission),
236
+ falls back to direct screencapture if Electron is not available.
237
+
238
+ Returns None on success, error message on failure.
239
+ """
240
+ # Try Electron desktop app first (has Screen Recording permission)
241
+ import asyncio
242
+
243
+ electron_result = await asyncio.to_thread(
244
+ self._capture_via_electron_sync, output_path, display
245
+ )
246
+ if electron_result is None:
247
+ logger.info("Screenshot captured via Electron desktop app")
248
+ return None # Success
249
+ if electron_result != "UNAVAILABLE":
250
+ return electron_result # Electron was available but capture failed
251
+
252
+ # Fallback: direct screencapture (works if this process has TCC permission)
253
+ logger.debug("Electron not available, falling back to direct screencapture")
254
+
255
+ if not shutil.which("screencapture"):
256
+ return "Error: 'screencapture' command not found"
257
+
258
+ cmd = ["screencapture", "-x"] # -x = no sound
259
+
260
+ # Add display selection if not main display
261
+ if display > 0:
262
+ cmd.extend(["-D", str(display + 1)]) # screencapture uses 1-based indexing
263
+
264
+ cmd.append(str(output_path))
265
+
266
+ try:
267
+ result = subprocess.run(
268
+ cmd,
269
+ capture_output=True,
270
+ text=True,
271
+ timeout=30
272
+ )
273
+
274
+ if result.returncode != 0:
275
+ error = result.stderr.strip() or "Unknown error"
276
+ error_lower = error.lower()
277
+ if "could not create image from display" in error_lower:
278
+ return (
279
+ "Error: macOS screenshot failed (display capture unavailable).\n"
280
+ "Possible causes:\n"
281
+ "1) Screen Recording permission not granted to Flowly\n"
282
+ "2) Process is not running in an active GUI (Aqua) session\n\n"
283
+ "Fix:\n"
284
+ "- System Settings -> Privacy & Security -> Screen Recording\n"
285
+ "- Open Flowly Desktop app (automatic permission delegation)\n"
286
+ "- Or manually grant permission to the Flowly binary"
287
+ )
288
+ if "operation not permitted" in error_lower or "not authorized" in error_lower:
289
+ return (
290
+ "Error: Screen Recording permission denied.\n"
291
+ "Open Flowly Desktop app or grant permission via\n"
292
+ "System Settings -> Privacy & Security -> Screen Recording."
293
+ )
294
+ return f"Error: screencapture failed - {error}"
295
+
296
+ return None # Success
297
+
298
+ except subprocess.TimeoutExpired:
299
+ return "Error: Screenshot timed out after 30 seconds"
300
+ except Exception as e:
301
+ return f"Error running screencapture: {str(e)}"
302
+
303
+ async def _capture_linux(self, output_path: Path, display: int) -> str | None:
304
+ """
305
+ Capture screenshot on Linux using gnome-screenshot, scrot, or import.
306
+
307
+ Returns None on success, error message on failure.
308
+ """
309
+ # Try different screenshot tools in order of preference
310
+ if shutil.which("gnome-screenshot"):
311
+ cmd = ["gnome-screenshot", "-f", str(output_path)]
312
+ elif shutil.which("scrot"):
313
+ cmd = ["scrot", str(output_path)]
314
+ elif shutil.which("import"):
315
+ # ImageMagick's import
316
+ cmd = ["import", "-window", "root", str(output_path)]
317
+ elif shutil.which("grim"):
318
+ # For Wayland
319
+ cmd = ["grim", str(output_path)]
320
+ else:
321
+ return (
322
+ "Error: No screenshot tool found. "
323
+ "Install one of: gnome-screenshot, scrot, imagemagick, or grim"
324
+ )
325
+
326
+ try:
327
+ result = subprocess.run(
328
+ cmd,
329
+ capture_output=True,
330
+ text=True,
331
+ timeout=30
332
+ )
333
+
334
+ if result.returncode != 0:
335
+ error = result.stderr.strip() or "Unknown error"
336
+ return f"Error: Screenshot command failed - {error}"
337
+
338
+ return None # Success
339
+
340
+ except subprocess.TimeoutExpired:
341
+ return "Error: Screenshot timed out after 30 seconds"
342
+ except Exception as e:
343
+ return f"Error running screenshot command: {str(e)}"
344
+
345
+ async def _capture_windows(self, output_path: Path, display: int) -> str | None:
346
+ """
347
+ Capture screenshot on Windows using PowerShell.
348
+
349
+ Returns None on success, error message on failure.
350
+ """
351
+ # PowerShell script to capture screen
352
+ # Use forward slashes to avoid PowerShell escape issues with backslashes
353
+ safe_path = str(output_path).replace("\\", "/")
354
+ ps_script = f'''
355
+ Add-Type -AssemblyName System.Windows.Forms
356
+ $screen = [System.Windows.Forms.Screen]::AllScreens[{display}]
357
+ $bounds = $screen.Bounds
358
+ $bitmap = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
359
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
360
+ $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
361
+ $bitmap.Save("{safe_path}")
362
+ $graphics.Dispose()
363
+ $bitmap.Dispose()
364
+ '''
365
+
366
+ try:
367
+ result = subprocess.run(
368
+ ["powershell", "-Command", ps_script],
369
+ capture_output=True,
370
+ text=True,
371
+ timeout=30
372
+ )
373
+
374
+ if result.returncode != 0:
375
+ error = result.stderr.strip() or "Unknown error"
376
+ return f"Error: PowerShell screenshot failed - {error}"
377
+
378
+ return None # Success
379
+
380
+ except subprocess.TimeoutExpired:
381
+ return "Error: Screenshot timed out after 30 seconds"
382
+ except FileNotFoundError:
383
+ return "Error: PowerShell not found"
384
+ except Exception as e:
385
+ return f"Error running PowerShell: {str(e)}"
386
+
387
+ def get_screenshots_dir(self) -> Path:
388
+ """Get the screenshots directory path."""
389
+ return self._screenshots_dir
390
+
391
+ def list_screenshots(self, limit: int = 10) -> list[Path]:
392
+ """
393
+ List recent screenshots.
394
+
395
+ Args:
396
+ limit: Maximum number of screenshots to return.
397
+
398
+ Returns:
399
+ List of screenshot paths, newest first.
400
+ """
401
+ screenshots = []
402
+ for ext in self.SUPPORTED_FORMATS:
403
+ screenshots.extend(self._screenshots_dir.glob(f"*.{ext}"))
404
+
405
+ # Sort by modification time, newest first
406
+ screenshots.sort(key=lambda p: p.stat().st_mtime, reverse=True)
407
+
408
+ return screenshots[:limit]
409
+
410
+ def cleanup_old_screenshots(self, max_age_days: int = 7, max_count: int = 100) -> int:
411
+ """
412
+ Clean up old screenshots to prevent disk space issues.
413
+
414
+ Args:
415
+ max_age_days: Delete screenshots older than this.
416
+ max_count: Keep at most this many screenshots.
417
+
418
+ Returns:
419
+ Number of files deleted.
420
+ """
421
+ from datetime import timedelta
422
+
423
+ deleted = 0
424
+ now = datetime.now()
425
+ cutoff = now - timedelta(days=max_age_days)
426
+
427
+ screenshots = self.list_screenshots(limit=1000)
428
+
429
+ for i, path in enumerate(screenshots):
430
+ try:
431
+ mtime = datetime.fromtimestamp(path.stat().st_mtime)
432
+
433
+ # Delete if too old or beyond max count
434
+ if mtime < cutoff or i >= max_count:
435
+ path.unlink()
436
+ deleted += 1
437
+ logger.debug(f"Deleted old screenshot: {path}")
438
+ except Exception as e:
439
+ logger.warning(f"Failed to delete {path}: {e}")
440
+
441
+ if deleted > 0:
442
+ logger.info(f"Cleaned up {deleted} old screenshots")
443
+
444
+ return deleted