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,1831 @@
1
+ """CLI commands for flowly."""
2
+
3
+ import asyncio
4
+ import os
5
+ import platform
6
+ import plistlib
7
+ import shlex
8
+ import shutil
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import textwrap
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ import typer
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from flowly_code import __version__, __logo__
22
+
23
+ # Windows needs SelectorEventLoop for uvicorn/aiohttp compatibility
24
+ if platform.system() == "Windows":
25
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
26
+
27
+
28
+ app = typer.Typer(
29
+ name="flowly-code",
30
+ help=f"{__logo__} flowly-code - Personal AI Assistant",
31
+ no_args_is_help=True,
32
+ )
33
+
34
+ console = Console()
35
+
36
+
37
+ def version_callback(value: bool):
38
+ if value:
39
+ console.print(f"{__logo__} flowly v{__version__}")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ version: bool = typer.Option(
46
+ None, "--version", "-v", callback=version_callback, is_eager=True
47
+ ),
48
+ ):
49
+ """flowly - Personal AI Assistant."""
50
+ pass
51
+
52
+
53
+ # ============================================================================
54
+ # Setup Commands
55
+ # ============================================================================
56
+
57
+ setup_app = typer.Typer(help="Interactive setup wizards")
58
+ app.add_typer(setup_app, name="setup")
59
+
60
+
61
+ @setup_app.callback(invoke_without_command=True)
62
+ def setup_main(ctx: typer.Context):
63
+ """Run the full setup wizard (or use subcommands for specific setups)."""
64
+ if ctx.invoked_subcommand is None:
65
+ from flowly_code.cli.setup import setup_all
66
+ setup_all()
67
+
68
+
69
+
70
+
71
+
72
+ @setup_app.command("openrouter")
73
+ def setup_openrouter_cmd():
74
+ """Set up OpenRouter LLM provider."""
75
+ from flowly_code.cli.setup import setup_openrouter
76
+ setup_openrouter()
77
+
78
+
79
+ @setup_app.command("trello")
80
+ def setup_trello_cmd():
81
+ """Set up Trello integration."""
82
+ from flowly_code.cli.setup import setup_trello
83
+ setup_trello()
84
+
85
+
86
+
87
+
88
+ @setup_app.command("agents")
89
+ def setup_agents_cmd():
90
+ """Set up multi-agent orchestration."""
91
+ from flowly_code.cli.setup import setup_agents
92
+ setup_agents()
93
+
94
+
95
+ # ============================================================================
96
+ # Persona Commands
97
+ # ============================================================================
98
+
99
+ persona_app = typer.Typer(help="Manage bot persona")
100
+ app.add_typer(persona_app, name="persona")
101
+
102
+ BUILTIN_PERSONAS = ["default", "jarvis", "friday", "pirate", "samurai", "casual", "professor", "butler"]
103
+
104
+
105
+ def _get_personas_dir() -> Path:
106
+ """Get the personas directory from workspace config."""
107
+ from flowly_code.config.loader import load_config
108
+ config = load_config()
109
+ return config.workspace_path / "personas"
110
+
111
+
112
+ def _ensure_personas(workspace: Path) -> Path:
113
+ """Ensure personas directory exists, copying builtins if needed."""
114
+ personas_dir = workspace / "personas"
115
+ if not personas_dir.exists() or not any(personas_dir.glob("*.md")):
116
+ _install_persona_files(workspace)
117
+ return personas_dir
118
+
119
+
120
+ @persona_app.command("list")
121
+ def persona_list():
122
+ """List available personas."""
123
+ from flowly_code.config.loader import load_config
124
+ config = load_config()
125
+ personas_dir = _ensure_personas(config.workspace_path)
126
+ active = config.agents.defaults.persona
127
+
128
+ if not any(personas_dir.glob("*.md")):
129
+ console.print("[yellow]No persona files found.[/yellow]")
130
+ raise typer.Exit(1)
131
+
132
+ table = Table(title="Available Personas")
133
+ table.add_column("Name", style="cyan")
134
+ table.add_column("Active", justify="center")
135
+ table.add_column("Description", style="dim")
136
+
137
+ for md_file in sorted(personas_dir.glob("*.md")):
138
+ name = md_file.stem
139
+ is_active = "[green]✓[/green]" if name == active else ""
140
+ # Read first non-header line as description
141
+ desc = ""
142
+ for line in md_file.read_text(encoding="utf-8").splitlines():
143
+ line = line.strip()
144
+ if line and not line.startswith("#"):
145
+ desc = line[:60]
146
+ break
147
+ table.add_row(name, is_active, desc)
148
+
149
+ console.print(table)
150
+
151
+
152
+ @persona_app.command("set")
153
+ def persona_set(
154
+ name: str = typer.Argument(help="Persona name to activate"),
155
+ ):
156
+ """Set the active persona."""
157
+ from flowly_code.config.loader import load_config, save_config
158
+ config = load_config()
159
+ personas_dir = config.workspace_path / "personas"
160
+ persona_file = personas_dir / f"{name}.md"
161
+
162
+ if not persona_file.exists():
163
+ console.print(f"[red]Persona not found: {name}[/red]")
164
+ available = [f.stem for f in personas_dir.glob("*.md")] if personas_dir.exists() else BUILTIN_PERSONAS
165
+ console.print(f"[dim]Available: {', '.join(available)}[/dim]")
166
+ raise typer.Exit(1)
167
+
168
+ config.agents.defaults.persona = name
169
+ save_config(config)
170
+ console.print(f"[green]✓[/green] Persona set to: [cyan]{name}[/cyan]")
171
+
172
+ # Auto-restart if gateway is running
173
+ ok, _ = _service_health(config.gateway.port)
174
+ if ok:
175
+ console.print("[dim]Restarting gateway...[/dim]")
176
+ try:
177
+ service_restart(label=DEFAULT_SERVICE_LABEL)
178
+ except (SystemExit, Exception):
179
+ console.print("[yellow]Could not auto-restart. Run: flowly service restart[/yellow]")
180
+
181
+
182
+ @persona_app.command("show")
183
+ def persona_show(
184
+ name: str = typer.Argument(help="Persona name to display"),
185
+ ):
186
+ """Show persona details."""
187
+ from flowly_code.config.loader import load_config
188
+ config = load_config()
189
+ persona_file = config.workspace_path / "personas" / f"{name}.md"
190
+
191
+ if not persona_file.exists():
192
+ console.print(f"[red]Persona not found: {name}[/red]")
193
+ raise typer.Exit(1)
194
+
195
+ content = persona_file.read_text(encoding="utf-8")
196
+ from rich.markdown import Markdown
197
+ console.print(Markdown(content))
198
+
199
+
200
+ # ============================================================================
201
+ # Service Commands
202
+ # ============================================================================
203
+
204
+ service_app = typer.Typer(help="Manage background gateway service")
205
+ app.add_typer(service_app, name="service")
206
+
207
+ DEFAULT_SERVICE_LABEL = "ai.flowly.gateway"
208
+
209
+
210
+ def _resolve_flowly_exec_argv() -> list[str]:
211
+ """Resolve the executable argv prefix used for service definitions."""
212
+ flowly_bin = shutil.which("flowly-code")
213
+ if flowly_bin:
214
+ return [str(Path(flowly_bin).expanduser())]
215
+
216
+ argv0 = Path(sys.argv[0]).expanduser()
217
+ if argv0.exists() and argv0.name == "flowly-code":
218
+ return [str(argv0)]
219
+
220
+ local_bin = (Path.home() / ".local" / "bin" / "flowly-code").expanduser()
221
+ if local_bin.exists():
222
+ return [str(local_bin)]
223
+
224
+ uv_bin = shutil.which("uv")
225
+ if uv_bin:
226
+ return [str(Path(uv_bin).expanduser()), "run", "flowly-code"]
227
+
228
+ return ["flowly-code"]
229
+
230
+
231
+ def _service_paths(label: str) -> tuple[Path | None, Path | None, Path | None]:
232
+ """Return service file paths for macOS/Linux/Windows."""
233
+ system = platform.system().lower()
234
+ if system == "darwin":
235
+ return Path.home() / "Library" / "LaunchAgents" / f"{label}.plist", None, None
236
+ if system == "linux":
237
+ return None, Path.home() / ".config" / "systemd" / "user" / f"{label}.service", None
238
+ if system == "windows":
239
+ return None, None, Path.home() / "AppData" / "Local" / "flowly" / f"{label}.xml"
240
+ return None, None, None
241
+
242
+
243
+ def _get_log_dir() -> Path:
244
+ """Return platform-appropriate log directory for gateway."""
245
+ system = platform.system().lower()
246
+ if system == "darwin":
247
+ return Path("/tmp")
248
+ if system == "windows":
249
+ log_dir = Path.home() / "AppData" / "Local" / "flowly" / "logs"
250
+ log_dir.mkdir(parents=True, exist_ok=True)
251
+ return log_dir
252
+ # Linux uses journalctl, but provide a fallback path
253
+ return Path("/tmp")
254
+
255
+
256
+ def _run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
257
+ """Run command and return completed process with text output."""
258
+ proc = subprocess.run(args, capture_output=True, text=True)
259
+ if check and proc.returncode != 0:
260
+ stderr = (proc.stderr or proc.stdout or "").strip()
261
+ raise RuntimeError(f"{' '.join(args)} failed: {stderr}")
262
+ return proc
263
+
264
+
265
+ def _service_health(port: int) -> tuple[bool, str]:
266
+ """Check local gateway health endpoint."""
267
+ url = f"http://127.0.0.1:{port}/health"
268
+ try:
269
+ with urllib.request.urlopen(url, timeout=2.0) as resp:
270
+ if 200 <= int(resp.status) < 300:
271
+ return True, f"{url} OK"
272
+ return False, f"{url} HTTP {resp.status}"
273
+ except urllib.error.URLError as e:
274
+ return False, f"{url} unavailable ({e.reason})"
275
+ except Exception as e:
276
+ return False, f"{url} unavailable ({e})"
277
+
278
+
279
+ def _kill_gateway_on_port(port: int, wait: float = 2.0) -> bool:
280
+ """Kill any process listening on the gateway port. Returns True if killed."""
281
+ system = platform.system().lower()
282
+ try:
283
+ if system == "darwin" or system == "linux":
284
+ result = subprocess.run(
285
+ ["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
286
+ capture_output=True, text=True, timeout=5,
287
+ )
288
+ pids = [int(p) for p in result.stdout.strip().split("\n") if p.strip()]
289
+ for pid in pids:
290
+ try:
291
+ os.kill(pid, signal.SIGTERM)
292
+ except ProcessLookupError:
293
+ pass
294
+ if pids:
295
+ import time
296
+ time.sleep(wait)
297
+ # SIGKILL any survivors
298
+ for pid in pids:
299
+ try:
300
+ os.kill(pid, signal.SIGKILL)
301
+ except ProcessLookupError:
302
+ pass
303
+ return True
304
+ elif system == "windows":
305
+ result = subprocess.run(
306
+ ["netstat", "-ano"], capture_output=True, text=True, timeout=5,
307
+ )
308
+ for line in result.stdout.splitlines():
309
+ if f":{port}" in line and "LISTENING" in line:
310
+ pid = int(line.strip().split()[-1])
311
+ subprocess.run(
312
+ ["taskkill", "/pid", str(pid), "/T", "/F"],
313
+ capture_output=True, timeout=5,
314
+ )
315
+ return True
316
+ except Exception:
317
+ pass
318
+ return False
319
+
320
+
321
+ def _extract_port_from_plist(plist_path: Path) -> int:
322
+ if not plist_path.exists():
323
+ return 18790
324
+ try:
325
+ raw = plist_path.read_bytes()
326
+ data = plistlib.loads(raw)
327
+ args = data.get("ProgramArguments", [])
328
+ if "--port" in args:
329
+ idx = args.index("--port")
330
+ if idx + 1 < len(args):
331
+ return int(args[idx + 1])
332
+ except Exception:
333
+ pass
334
+ return 18790
335
+
336
+
337
+ def _extract_port_from_unit(unit_path: Path) -> int:
338
+ if not unit_path.exists():
339
+ return 18790
340
+ try:
341
+ content = unit_path.read_text(encoding="utf-8")
342
+ except Exception:
343
+ return 18790
344
+ marker = "--port"
345
+ if marker not in content:
346
+ return 18790
347
+ try:
348
+ after = content.split(marker, 1)[1].strip()
349
+ return int(after.split()[0])
350
+ except Exception:
351
+ return 18790
352
+
353
+
354
+ def _extract_port_from_win_xml(xml_path: Path) -> int:
355
+ """Extract --port value from Windows Task Scheduler XML."""
356
+ if not xml_path.exists():
357
+ return 18790
358
+ try:
359
+ content = xml_path.read_text(encoding="utf-16")
360
+ except Exception:
361
+ return 18790
362
+ marker = "--port"
363
+ if marker not in content:
364
+ return 18790
365
+ try:
366
+ after = content.split(marker, 1)[1].strip()
367
+ return int(after.split()[0].strip('"').strip("'"))
368
+ except Exception:
369
+ return 18790
370
+
371
+
372
+ @service_app.command("install")
373
+ def service_install(
374
+ label: str = typer.Option(DEFAULT_SERVICE_LABEL, "--label", help="Service label"),
375
+ port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
376
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable gateway verbose mode"),
377
+ start: bool = typer.Option(True, "--start/--no-start", help="Start service after install"),
378
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing service file"),
379
+ persona: str = typer.Option("", "--persona", help="Bot persona (default, jarvis, pirate, samurai, casual, professor, butler, friday)"),
380
+ ):
381
+ """Install background service for flowly gateway."""
382
+ mac_plist, linux_unit, win_xml = _service_paths(label)
383
+ exec_argv = _resolve_flowly_exec_argv()
384
+ system = platform.system().lower()
385
+
386
+ if system == "darwin" and mac_plist:
387
+ mac_plist.parent.mkdir(parents=True, exist_ok=True)
388
+ if mac_plist.exists() and not force:
389
+ console.print(f"[yellow]Service file exists: {mac_plist}[/yellow]")
390
+ console.print("[dim]Use --force to overwrite.[/dim]")
391
+ raise typer.Exit(1)
392
+
393
+ argv = exec_argv + ["gateway", "--port", str(port)]
394
+ if verbose:
395
+ argv.append("--verbose")
396
+ if persona:
397
+ argv.extend(["--persona", persona])
398
+ plist_obj = {
399
+ "Label": label,
400
+ "ProgramArguments": argv,
401
+ "RunAtLoad": True,
402
+ "KeepAlive": True,
403
+ "LimitLoadToSessionType": "Aqua",
404
+ "ProcessType": "Interactive",
405
+ "WorkingDirectory": str(Path.cwd()),
406
+ "StandardOutPath": str(_get_log_dir() / "flowly-gateway.out.log"),
407
+ "StandardErrorPath": str(_get_log_dir() / "flowly-gateway.err.log"),
408
+ "EnvironmentVariables": {
409
+ "PATH": os.environ.get("PATH", ""),
410
+ "PYTHONUNBUFFERED": "1",
411
+ },
412
+ }
413
+ mac_plist.write_bytes(plistlib.dumps(plist_obj, fmt=plistlib.FMT_XML, sort_keys=False))
414
+
415
+ try:
416
+ _run_cmd(["launchctl", "unload", str(mac_plist)], check=False)
417
+ _run_cmd(["launchctl", "load", str(mac_plist)])
418
+ if start:
419
+ _run_cmd(["launchctl", "start", label], check=False)
420
+ except Exception as e:
421
+ console.print(f"[red]Service install failed: {e}[/red]")
422
+ raise typer.Exit(1)
423
+
424
+ console.print(f"[green]✓[/green] Installed launchd service: {label}")
425
+ console.print(f"[dim]File: {mac_plist}[/dim]")
426
+ return
427
+
428
+ if system == "linux" and linux_unit:
429
+ linux_unit.parent.mkdir(parents=True, exist_ok=True)
430
+ if linux_unit.exists() and not force:
431
+ console.print(f"[yellow]Service file exists: {linux_unit}[/yellow]")
432
+ console.print("[dim]Use --force to overwrite.[/dim]")
433
+ raise typer.Exit(1)
434
+
435
+ argv = exec_argv + ["gateway", "--port", str(port)]
436
+ if verbose:
437
+ argv.append("--verbose")
438
+ if persona:
439
+ argv.extend(["--persona", persona])
440
+ exec_line = shlex.join(argv)
441
+ unit_content = textwrap.dedent(
442
+ f"""\
443
+ [Unit]
444
+ Description=Flowly Gateway Service
445
+ After=network.target
446
+
447
+ [Service]
448
+ Type=simple
449
+ ExecStart={exec_line}
450
+ Restart=always
451
+ RestartSec=3
452
+ WorkingDirectory={Path.cwd()}
453
+ Environment=PYTHONUNBUFFERED=1
454
+
455
+ [Install]
456
+ WantedBy=default.target
457
+ """
458
+ )
459
+ linux_unit.write_text(unit_content, encoding="utf-8")
460
+
461
+ try:
462
+ _run_cmd(["systemctl", "--user", "daemon-reload"])
463
+ _run_cmd(["systemctl", "--user", "enable", label])
464
+ if start:
465
+ _run_cmd(["systemctl", "--user", "restart", label])
466
+ except Exception as e:
467
+ console.print(f"[red]Service install failed: {e}[/red]")
468
+ console.print("[dim]Tip: Ensure user systemd is available (login session).[/dim]")
469
+ raise typer.Exit(1)
470
+
471
+ console.print(f"[green]✓[/green] Installed systemd user service: {label}")
472
+ console.print(f"[dim]File: {linux_unit}[/dim]")
473
+ return
474
+
475
+ if system == "windows" and win_xml:
476
+ win_xml.parent.mkdir(parents=True, exist_ok=True)
477
+ if win_xml.exists() and not force:
478
+ console.print(f"[yellow]Service file exists: {win_xml}[/yellow]")
479
+ console.print("[dim]Use --force to overwrite.[/dim]")
480
+ raise typer.Exit(1)
481
+
482
+ log_dir = _get_log_dir()
483
+ argv = exec_argv + ["gateway", "--port", str(port)]
484
+ if verbose:
485
+ argv.append("--verbose")
486
+ if persona:
487
+ argv.extend(["--persona", persona])
488
+
489
+ command = argv[0]
490
+ arguments = " ".join(argv[1:]) if len(argv) > 1 else ""
491
+ out_log = str(log_dir / "flowly-gateway.out.log")
492
+ err_log = str(log_dir / "flowly-gateway.err.log")
493
+
494
+ # Escape XML special characters in dynamic values
495
+ def _xml_escape(s: str) -> str:
496
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
497
+
498
+ # Use cmd /c wrapper to redirect stdout/stderr to log files
499
+ wrapper_args = f'/c "{command}" {arguments} > "{out_log}" 2> "{err_log}"'
500
+ working_dir = str(Path.cwd())
501
+
502
+ task_xml = textwrap.dedent(
503
+ f"""\
504
+ <?xml version="1.0" encoding="UTF-16"?>
505
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
506
+ <RegistrationInfo>
507
+ <Description>Flowly Gateway Service</Description>
508
+ </RegistrationInfo>
509
+ <Triggers>
510
+ <LogonTrigger>
511
+ <Enabled>true</Enabled>
512
+ </LogonTrigger>
513
+ </Triggers>
514
+ <Principals>
515
+ <Principal id="Author">
516
+ <LogonType>InteractiveToken</LogonType>
517
+ <RunLevel>LeastPrivilege</RunLevel>
518
+ </Principal>
519
+ </Principals>
520
+ <Settings>
521
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
522
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
523
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
524
+ <AllowHardTerminate>true</AllowHardTerminate>
525
+ <StartWhenAvailable>true</StartWhenAvailable>
526
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
527
+ <AllowStartOnDemand>true</AllowStartOnDemand>
528
+ <Enabled>true</Enabled>
529
+ <Hidden>false</Hidden>
530
+ <RestartOnFailure>
531
+ <Interval>PT1M</Interval>
532
+ <Count>10</Count>
533
+ </RestartOnFailure>
534
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
535
+ </Settings>
536
+ <Actions Context="Author">
537
+ <Exec>
538
+ <Command>cmd.exe</Command>
539
+ <Arguments>{_xml_escape(wrapper_args)}</Arguments>
540
+ <WorkingDirectory>{_xml_escape(working_dir)}</WorkingDirectory>
541
+ </Exec>
542
+ </Actions>
543
+ </Task>
544
+ """
545
+ )
546
+ win_xml.write_text(task_xml, encoding="utf-16")
547
+
548
+ try:
549
+ _run_cmd(["schtasks", "/create", "/tn", label, "/xml", str(win_xml), "/f"])
550
+ if start:
551
+ _run_cmd(["schtasks", "/run", "/tn", label], check=False)
552
+ except Exception as e:
553
+ console.print(f"[red]Service install failed: {e}[/red]")
554
+ console.print("[dim]Tip: You may need to run as Administrator.[/dim]")
555
+ raise typer.Exit(1)
556
+
557
+ console.print(f"[green]✓[/green] Installed Windows Task Scheduler service: {label}")
558
+ console.print(f"[dim]File: {win_xml}[/dim]")
559
+ return
560
+
561
+ console.print(f"[red]Unsupported platform for service install: {platform.system()}[/red]")
562
+ raise typer.Exit(1)
563
+
564
+
565
+ @service_app.command("start")
566
+ def service_start(
567
+ label: str = typer.Option(DEFAULT_SERVICE_LABEL, "--label", help="Service label"),
568
+ ):
569
+ """Start installed background service."""
570
+ mac_plist, linux_unit, win_xml = _service_paths(label)
571
+ system = platform.system().lower()
572
+ try:
573
+ if system == "darwin" and mac_plist:
574
+ if not mac_plist.exists():
575
+ console.print(f"[red]Service not installed: {mac_plist}[/red]")
576
+ raise typer.Exit(1)
577
+ _run_cmd(["launchctl", "load", str(mac_plist)], check=False)
578
+ _run_cmd(["launchctl", "start", label], check=False)
579
+ console.print(f"[green]✓[/green] Started service {label}")
580
+ return
581
+ if system == "linux":
582
+ _run_cmd(["systemctl", "--user", "start", label])
583
+ console.print(f"[green]✓[/green] Started service {label}")
584
+ return
585
+ if system == "windows":
586
+ if win_xml and not win_xml.exists():
587
+ console.print("[red]Service not installed. Run 'flowly service install' first.[/red]")
588
+ raise typer.Exit(1)
589
+ _run_cmd(["schtasks", "/run", "/tn", label])
590
+ console.print(f"[green]✓[/green] Started service {label}")
591
+ return
592
+ except Exception as e:
593
+ console.print(f"[red]Failed to start service: {e}[/red]")
594
+ raise typer.Exit(1)
595
+ console.print(f"[red]Unsupported platform: {platform.system()}[/red]")
596
+ raise typer.Exit(1)
597
+
598
+
599
+ @service_app.command("stop")
600
+ def service_stop(
601
+ label: str = typer.Option(DEFAULT_SERVICE_LABEL, "--label", help="Service label"),
602
+ ):
603
+ """Stop background service."""
604
+ mac_plist, linux_unit, win_xml = _service_paths(label)
605
+ system = platform.system().lower()
606
+
607
+ # Determine the port so we can force-kill if needed
608
+ port = 18790
609
+ if system == "darwin" and mac_plist:
610
+ port = _extract_port_from_plist(mac_plist)
611
+ elif system == "linux" and linux_unit:
612
+ port = _extract_port_from_unit(linux_unit)
613
+ elif system == "windows" and win_xml:
614
+ port = _extract_port_from_win_xml(win_xml)
615
+
616
+ try:
617
+ if system == "darwin" and mac_plist:
618
+ _run_cmd(["launchctl", "stop", label], check=False)
619
+ _run_cmd(["launchctl", "unload", str(mac_plist)], check=False)
620
+ elif system == "linux":
621
+ _run_cmd(["systemctl", "--user", "stop", label], check=False)
622
+ elif system == "windows":
623
+ _run_cmd(["schtasks", "/end", "/tn", label], check=False)
624
+ else:
625
+ console.print(f"[red]Unsupported platform: {platform.system()}[/red]")
626
+ raise typer.Exit(1)
627
+
628
+ # Force-kill any remaining process on the port
629
+ _kill_gateway_on_port(port)
630
+ console.print(f"[green]✓[/green] Stopped service {label}")
631
+ except typer.Exit:
632
+ raise
633
+ except Exception as e:
634
+ console.print(f"[red]Failed to stop service: {e}[/red]")
635
+ raise typer.Exit(1)
636
+
637
+
638
+ @service_app.command("restart")
639
+ def service_restart(
640
+ label: str = typer.Option(DEFAULT_SERVICE_LABEL, "--label", help="Service label"),
641
+ ):
642
+ """Restart background service."""
643
+ system = platform.system().lower()
644
+ try:
645
+ if system == "darwin":
646
+ service_stop(label=label)
647
+ service_start(label=label)
648
+ return
649
+ if system == "linux":
650
+ _run_cmd(["systemctl", "--user", "restart", label])
651
+ console.print(f"[green]✓[/green] Restarted service {label}")
652
+ return
653
+ if system == "windows":
654
+ service_stop(label=label)
655
+ service_start(label=label)
656
+ return
657
+ except Exception as e:
658
+ console.print(f"[red]Failed to restart service: {e}[/red]")
659
+ raise typer.Exit(1)
660
+ console.print(f"[red]Unsupported platform: {platform.system()}[/red]")
661
+ raise typer.Exit(1)
662
+
663
+
664
+ @service_app.command("status")
665
+ def service_status(
666
+ label: str = typer.Option(DEFAULT_SERVICE_LABEL, "--label", help="Service label"),
667
+ ):
668
+ """Show service state and local health."""
669
+ mac_plist, linux_unit, win_xml = _service_paths(label)
670
+ system = platform.system().lower()
671
+
672
+ if system == "darwin" and mac_plist:
673
+ installed = mac_plist.exists()
674
+ loaded = False
675
+ pid = ""
676
+ try:
677
+ proc = _run_cmd(["launchctl", "list", label], check=False)
678
+ loaded = proc.returncode == 0
679
+ output = proc.stdout or ""
680
+ for line in output.splitlines():
681
+ if "pid" in line.lower():
682
+ pid = line.strip()
683
+ break
684
+ except Exception:
685
+ loaded = False
686
+ port = _extract_port_from_plist(mac_plist)
687
+ ok, health = _service_health(port)
688
+ console.print(f"Service: [cyan]{label}[/cyan]")
689
+ console.print(f"Installed: {'[green]yes[/green]' if installed else '[red]no[/red]'}")
690
+ console.print(f"Loaded: {'[green]yes[/green]' if loaded else '[red]no[/red]'}")
691
+ if pid:
692
+ console.print(f"PID info: [dim]{pid}[/dim]")
693
+ console.print(f"Health: {'[green]ok[/green]' if ok else '[yellow]down[/yellow]'} - {health}")
694
+ if installed:
695
+ console.print(f"[dim]File: {mac_plist}[/dim]")
696
+ return
697
+
698
+ if system == "linux" and linux_unit:
699
+ installed = linux_unit.exists()
700
+ enabled = False
701
+ active = False
702
+ try:
703
+ enabled = _run_cmd(["systemctl", "--user", "is-enabled", label], check=False).returncode == 0
704
+ active = _run_cmd(["systemctl", "--user", "is-active", label], check=False).returncode == 0
705
+ except Exception:
706
+ pass
707
+ port = _extract_port_from_unit(linux_unit)
708
+ ok, health = _service_health(port)
709
+ console.print(f"Service: [cyan]{label}[/cyan]")
710
+ console.print(f"Installed: {'[green]yes[/green]' if installed else '[red]no[/red]'}")
711
+ console.print(f"Enabled: {'[green]yes[/green]' if enabled else '[red]no[/red]'}")
712
+ console.print(f"Active: {'[green]yes[/green]' if active else '[red]no[/red]'}")
713
+ console.print(f"Health: {'[green]ok[/green]' if ok else '[yellow]down[/yellow]'} - {health}")
714
+ if installed:
715
+ console.print(f"[dim]File: {linux_unit}[/dim]")
716
+ return
717
+
718
+ if system == "windows" and win_xml:
719
+ installed = win_xml.exists()
720
+ running = False
721
+ status_text = "Unknown"
722
+ try:
723
+ proc = _run_cmd(
724
+ ["schtasks", "/query", "/tn", label, "/fo", "CSV", "/nh"],
725
+ check=False,
726
+ )
727
+ if proc.returncode == 0 and proc.stdout:
728
+ # CSV format: "task_name","Next Run","Status"
729
+ parts = proc.stdout.strip().split(",")
730
+ if len(parts) >= 3:
731
+ status_text = parts[2].strip().strip('"')
732
+ running = status_text.lower() == "running"
733
+ except Exception:
734
+ pass
735
+ port = _extract_port_from_win_xml(win_xml)
736
+ ok, health = _service_health(port)
737
+ console.print(f"Service: [cyan]{label}[/cyan]")
738
+ console.print(f"Installed: {'[green]yes[/green]' if installed else '[red]no[/red]'}")
739
+ console.print(f"Status: {'[green]Running[/green]' if running else f'[yellow]{status_text}[/yellow]'}")
740
+ console.print(f"Health: {'[green]ok[/green]' if ok else '[yellow]down[/yellow]'} - {health}")
741
+ if installed:
742
+ console.print(f"[dim]File: {win_xml}[/dim]")
743
+ return
744
+
745
+ console.print(f"[red]Unsupported platform: {platform.system()}[/red]")
746
+ raise typer.Exit(1)
747
+
748
+
749
+ @service_app.command("logs")
750
+ def service_logs(
751
+ label: str = typer.Option(DEFAULT_SERVICE_LABEL, "--label", help="Service label"),
752
+ follow: bool = typer.Option(True, "--follow/--no-follow", "-f", help="Follow logs in real time"),
753
+ lines: int = typer.Option(200, "--lines", "-n", min=1, help="Number of lines to show"),
754
+ stream: str = typer.Option(
755
+ "both",
756
+ "--stream",
757
+ help="Log stream (macOS launchd logs only): out|err|both",
758
+ ),
759
+ ):
760
+ """Show background service logs (real-time by default)."""
761
+ system = platform.system().lower()
762
+
763
+ if system == "darwin":
764
+ stream = stream.lower().strip()
765
+ if stream not in {"out", "err", "both"}:
766
+ console.print("[red]Invalid --stream value. Use out, err, or both.[/red]")
767
+ raise typer.Exit(1)
768
+
769
+ log_dir = _get_log_dir()
770
+ out_log = log_dir / "flowly-gateway.out.log"
771
+ err_log = log_dir / "flowly-gateway.err.log"
772
+ selected_files: list[Path] = []
773
+ if stream in {"out", "both"}:
774
+ selected_files.append(out_log)
775
+ if stream in {"err", "both"}:
776
+ selected_files.append(err_log)
777
+
778
+ existing_files = [p for p in selected_files if p.exists()]
779
+ missing_files = [p for p in selected_files if not p.exists()]
780
+ for missing in missing_files:
781
+ console.print(f"[yellow]Log file not found yet:[/yellow] {missing}")
782
+
783
+ if not existing_files:
784
+ console.print("[red]No log file available yet.[/red]")
785
+ raise typer.Exit(1)
786
+
787
+ if follow:
788
+ console.print(
789
+ f"[dim]Following logs ({', '.join(str(p) for p in existing_files)}). "
790
+ "Press Ctrl+C to stop.[/dim]"
791
+ )
792
+ try:
793
+ subprocess.run(
794
+ ["tail", "-n", str(lines), "-F", *[str(p) for p in existing_files]],
795
+ check=False,
796
+ )
797
+ except KeyboardInterrupt:
798
+ return
799
+ return
800
+
801
+ for file_path in existing_files:
802
+ console.print(f"\n[bold]{file_path}[/bold]")
803
+ proc = _run_cmd(["tail", "-n", str(lines), str(file_path)], check=False)
804
+ if proc.stdout:
805
+ console.print(proc.stdout.rstrip("\n"))
806
+ return
807
+
808
+ if system == "linux":
809
+ args = ["journalctl", "--user", "-u", label, "-n", str(lines), "--no-pager"]
810
+ if follow:
811
+ args.append("-f")
812
+ console.print(f"[dim]Following journal logs for {label}. Press Ctrl+C to stop.[/dim]")
813
+ proc = _run_cmd(args, check=False)
814
+ if proc.returncode != 0:
815
+ err = (proc.stderr or proc.stdout or "").strip()
816
+ console.print(f"[red]Failed to read logs: {err}[/red]")
817
+ raise typer.Exit(1)
818
+ if proc.stdout:
819
+ console.print(proc.stdout.rstrip("\n"))
820
+ return
821
+
822
+ if system == "windows":
823
+ log_dir = _get_log_dir()
824
+ out_log = log_dir / "flowly-gateway.out.log"
825
+ err_log = log_dir / "flowly-gateway.err.log"
826
+ selected_files: list[Path] = []
827
+ if stream in {"out", "both"}:
828
+ selected_files.append(out_log)
829
+ if stream in {"err", "both"}:
830
+ selected_files.append(err_log)
831
+
832
+ existing_files = [p for p in selected_files if p.exists()]
833
+ missing_files = [p for p in selected_files if not p.exists()]
834
+ for missing in missing_files:
835
+ console.print(f"[yellow]Log file not found yet:[/yellow] {missing}")
836
+
837
+ if not existing_files:
838
+ console.print("[red]No log file available yet.[/red]")
839
+ raise typer.Exit(1)
840
+
841
+ if follow:
842
+ console.print(
843
+ f"[dim]Following logs ({', '.join(str(p) for p in existing_files)}). "
844
+ "Press Ctrl+C to stop.[/dim]"
845
+ )
846
+ # Use PowerShell Get-Content -Wait for tail -f equivalent on Windows
847
+ ps_files = ", ".join(f'"{p}"' for p in existing_files)
848
+ ps_cmd = f"Get-Content -Path {ps_files} -Tail {lines} -Wait"
849
+ try:
850
+ subprocess.run(
851
+ ["powershell", "-Command", ps_cmd],
852
+ check=False,
853
+ )
854
+ except KeyboardInterrupt:
855
+ return
856
+ return
857
+
858
+ # Read last N lines using PowerShell
859
+ for file_path in existing_files:
860
+ console.print(f"\n[bold]{file_path}[/bold]")
861
+ ps_cmd = f'Get-Content -Path "{file_path}" -Tail {lines}'
862
+ proc = _run_cmd(["powershell", "-Command", ps_cmd], check=False)
863
+ if proc.stdout:
864
+ console.print(proc.stdout.rstrip("\n"))
865
+ return
866
+
867
+ console.print(f"[red]Unsupported platform: {platform.system()}[/red]")
868
+ raise typer.Exit(1)
869
+
870
+
871
+ @service_app.command("uninstall")
872
+ def service_uninstall(
873
+ label: str = typer.Option(DEFAULT_SERVICE_LABEL, "--label", help="Service label"),
874
+ ):
875
+ """Uninstall background service definition."""
876
+ mac_plist, linux_unit, win_xml = _service_paths(label)
877
+ system = platform.system().lower()
878
+
879
+ try:
880
+ if system == "darwin" and mac_plist:
881
+ _run_cmd(["launchctl", "stop", label], check=False)
882
+ _run_cmd(["launchctl", "unload", str(mac_plist)], check=False)
883
+ if mac_plist.exists():
884
+ mac_plist.unlink()
885
+ console.print(f"[green]✓[/green] Uninstalled service {label}")
886
+ return
887
+ if system == "linux" and linux_unit:
888
+ _run_cmd(["systemctl", "--user", "stop", label], check=False)
889
+ _run_cmd(["systemctl", "--user", "disable", label], check=False)
890
+ if linux_unit.exists():
891
+ linux_unit.unlink()
892
+ _run_cmd(["systemctl", "--user", "daemon-reload"], check=False)
893
+ console.print(f"[green]✓[/green] Uninstalled service {label}")
894
+ return
895
+ if system == "windows" and win_xml:
896
+ _run_cmd(["schtasks", "/end", "/tn", label], check=False)
897
+ _run_cmd(["schtasks", "/delete", "/tn", label, "/f"], check=False)
898
+ if win_xml.exists():
899
+ win_xml.unlink()
900
+ console.print(f"[green]✓[/green] Uninstalled service {label}")
901
+ return
902
+ except Exception as e:
903
+ console.print(f"[red]Failed to uninstall service: {e}[/red]")
904
+ raise typer.Exit(1)
905
+
906
+ console.print(f"[red]Unsupported platform: {platform.system()}[/red]")
907
+ raise typer.Exit(1)
908
+
909
+
910
+ # ============================================================================
911
+ # Onboard / Setup
912
+ # ============================================================================
913
+
914
+
915
+ @app.command()
916
+ def onboard():
917
+ """Initialize flowly configuration and workspace."""
918
+ from flowly_code.config.loader import get_config_path, save_config
919
+ from flowly_code.config.schema import Config
920
+ from flowly_code.utils.helpers import get_workspace_path
921
+
922
+ config_path = get_config_path()
923
+
924
+ if config_path.exists():
925
+ console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
926
+ if not typer.confirm("Overwrite?"):
927
+ raise typer.Exit()
928
+
929
+ # Create default config
930
+ config = Config()
931
+ save_config(config)
932
+ console.print(f"[green]✓[/green] Created config at {config_path}")
933
+
934
+ # Create workspace
935
+ workspace = get_workspace_path()
936
+ console.print(f"[green]✓[/green] Created workspace at {workspace}")
937
+
938
+ # Create default bootstrap files
939
+ _create_workspace_templates(workspace)
940
+
941
+ # Copy builtin persona files to workspace
942
+ _install_persona_files(workspace)
943
+
944
+ # Persona selection
945
+ console.print("\n[bold cyan]Choose a persona for your bot:[/bold cyan]")
946
+ personas_dir = workspace / "personas"
947
+ if personas_dir.exists():
948
+ choices = [f.stem for f in sorted(personas_dir.glob("*.md"))]
949
+ for i, name in enumerate(choices, 1):
950
+ marker = " [green](default)[/green]" if name == "default" else ""
951
+ console.print(f" {i}. [cyan]{name}[/cyan]{marker}")
952
+ choice = typer.prompt("Select persona number", default="1")
953
+ try:
954
+ idx = int(choice) - 1
955
+ if 0 <= idx < len(choices):
956
+ selected = choices[idx]
957
+ config.agents.defaults.persona = selected
958
+ save_config(config)
959
+ console.print(f"[green]✓[/green] Persona set to: [cyan]{selected}[/cyan]")
960
+ else:
961
+ console.print("[dim]Using default persona.[/dim]")
962
+ except ValueError:
963
+ console.print("[dim]Using default persona.[/dim]")
964
+
965
+ console.print(f"\n{__logo__} flowly is ready!")
966
+ console.print("\nNext steps:")
967
+ console.print(" 1. Add your API key to [cyan]~/.flowly/config.json[/cyan]")
968
+ console.print(" Get one at: https://openrouter.ai/keys")
969
+ console.print(" 2. Chat: [cyan]flowly agent -m \"Hello!\"[/cyan]")
970
+ console.print("[dim]Change persona later: flowly persona set <name>[/dim]")
971
+
972
+
973
+
974
+
975
+ def _create_workspace_templates(workspace: Path):
976
+ """Create default workspace template files."""
977
+ templates = {
978
+ "AGENTS.md": """# Agent Instructions
979
+
980
+ You are a helpful AI assistant. Be concise, accurate, and friendly.
981
+
982
+ ## Guidelines
983
+
984
+ - Always explain what you're doing before taking actions
985
+ - Ask for clarification when the request is ambiguous
986
+ - Use tools to help accomplish tasks
987
+ - Remember important information in your memory files
988
+ """,
989
+ "SOUL.md": """# Soul
990
+
991
+ I am Nanobot, your personal AI assistant.
992
+
993
+ ## Personality
994
+
995
+ - Helpful and friendly
996
+ - Concise and to the point
997
+ - Curious and eager to learn
998
+
999
+ ## Values
1000
+
1001
+ - Accuracy over speed
1002
+ - User privacy and safety
1003
+ - Transparency in actions
1004
+ """,
1005
+ "USER.md": """# User
1006
+
1007
+ Information about the user goes here.
1008
+
1009
+ ## Preferences
1010
+
1011
+ - Communication style: (casual/formal)
1012
+ - Timezone: (your timezone)
1013
+ - Language: (your preferred language)
1014
+ """,
1015
+ }
1016
+
1017
+ for filename, content in templates.items():
1018
+ file_path = workspace / filename
1019
+ if not file_path.exists():
1020
+ file_path.write_text(content, encoding="utf-8")
1021
+ console.print(f" [dim]Created {filename}[/dim]")
1022
+
1023
+ # Create memory directory and MEMORY.md
1024
+ memory_dir = workspace / "memory"
1025
+ memory_dir.mkdir(exist_ok=True)
1026
+ memory_file = memory_dir / "MEMORY.md"
1027
+ if not memory_file.exists():
1028
+ memory_file.write_text("""# Long-term Memory
1029
+
1030
+ This file stores important information that should persist across sessions.
1031
+
1032
+ ## User Information
1033
+
1034
+ (Important facts about the user)
1035
+
1036
+ ## Preferences
1037
+
1038
+ (User preferences learned over time)
1039
+
1040
+ ## Important Notes
1041
+
1042
+ (Things to remember)
1043
+ """, encoding="utf-8")
1044
+ console.print(" [dim]Created memory/MEMORY.md[/dim]")
1045
+
1046
+
1047
+ def _install_persona_files(workspace: Path):
1048
+ """Copy builtin persona files to workspace/personas/ directory."""
1049
+ personas_dir = workspace / "personas"
1050
+ personas_dir.mkdir(exist_ok=True)
1051
+
1052
+ # Builtin personas are shipped in the package's workspace/personas/ directory
1053
+ builtin_dir = Path(__file__).parent.parent.parent / "workspace" / "personas"
1054
+ if builtin_dir.exists():
1055
+ for src in builtin_dir.glob("*.md"):
1056
+ dst = personas_dir / src.name
1057
+ if not dst.exists():
1058
+ dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
1059
+ console.print(f" [dim]Created personas/{src.name}[/dim]")
1060
+ else:
1061
+ # Fallback: create a minimal default persona
1062
+ default_file = personas_dir / "default.md"
1063
+ if not default_file.exists():
1064
+ default_file.write_text(
1065
+ "# Persona: Flowly\n\n"
1066
+ "You are Flowly, a helpful AI assistant.\n\n"
1067
+ "## Personality\n\n"
1068
+ "- Helpful and friendly\n"
1069
+ "- Concise and to the point\n"
1070
+ "- Curious and eager to learn\n",
1071
+ encoding="utf-8",
1072
+ )
1073
+ console.print(" [dim]Created personas/default.md[/dim]")
1074
+
1075
+
1076
+ # ============================================================================
1077
+ # Gateway / Server
1078
+ # ============================================================================
1079
+
1080
+
1081
+ @app.command()
1082
+ def gateway(
1083
+ port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
1084
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
1085
+ persona: str = typer.Option("", "--persona", help="Bot persona (default, jarvis, pirate, samurai, casual, professor, butler, friday)"),
1086
+ ):
1087
+ """Start the flowly gateway."""
1088
+ from flowly_code.config.loader import load_config, get_data_dir
1089
+ from flowly_code.bus.queue import MessageBus
1090
+ from flowly_code.providers.litellm_provider import LiteLLMProvider
1091
+ from flowly_code.agent.loop import AgentLoop
1092
+ from flowly_code.heartbeat.service import HeartbeatService
1093
+ from flowly_code.gateway.server import GatewayServer
1094
+
1095
+ import logging
1096
+ if verbose:
1097
+ logging.basicConfig(level=logging.DEBUG)
1098
+ else:
1099
+ logging.basicConfig(level=logging.WARNING)
1100
+
1101
+ from flowly_code import __banner__
1102
+ console.print(f"[cyan]{__banner__.format(version=__version__)}[/cyan]")
1103
+ console.print(f"Starting gateway on port {port}...")
1104
+
1105
+ config = load_config()
1106
+
1107
+ # Resolve persona: CLI flag overrides config
1108
+ active_persona = persona if persona else config.agents.defaults.persona
1109
+ if active_persona:
1110
+ console.print(f"[dim]Persona: {active_persona}[/dim]")
1111
+
1112
+ # Create components
1113
+ bus = MessageBus()
1114
+
1115
+ # Activity bus for real-time event streaming (independent from MessageBus)
1116
+ from flowly_code.activity.bus import ActivityBus
1117
+ activity_bus = ActivityBus()
1118
+
1119
+ # Create provider (supports OpenRouter, Anthropic, OpenAI)
1120
+ api_key = config.get_api_key()
1121
+ api_base = config.get_api_base()
1122
+
1123
+ if not api_key:
1124
+ console.print("[red]Error: No API key configured.[/red]")
1125
+ console.print("Set one in ~/.flowly/config.json under providers.openrouter.apiKey")
1126
+ raise typer.Exit(1)
1127
+
1128
+ provider = LiteLLMProvider(
1129
+ api_key=api_key,
1130
+ api_base=api_base,
1131
+ default_model=config.agents.defaults.model
1132
+ )
1133
+
1134
+ # Build compaction config from settings
1135
+ from flowly_code.compaction.types import CompactionConfig, MemoryFlushConfig
1136
+ compaction_cfg = config.agents.defaults.compaction
1137
+ compaction_config = CompactionConfig(
1138
+ mode=compaction_cfg.mode,
1139
+ reserve_tokens_floor=compaction_cfg.reserve_tokens_floor,
1140
+ max_history_share=compaction_cfg.max_history_share,
1141
+ context_window=compaction_cfg.context_window,
1142
+ memory_flush=MemoryFlushConfig(
1143
+ enabled=compaction_cfg.memory_flush.enabled,
1144
+ soft_threshold_tokens=compaction_cfg.memory_flush.soft_threshold_tokens,
1145
+ prompt=compaction_cfg.memory_flush.prompt,
1146
+ system_prompt=compaction_cfg.memory_flush.system_prompt,
1147
+ ),
1148
+ )
1149
+
1150
+ # Build exec config
1151
+ from flowly_code.exec.types import ExecConfig
1152
+ exec_cfg = config.tools.exec
1153
+ exec_config = ExecConfig(
1154
+ enabled=exec_cfg.enabled,
1155
+ security=exec_cfg.security,
1156
+ ask=exec_cfg.ask,
1157
+ timeout_seconds=exec_cfg.timeout_seconds,
1158
+ max_output_chars=exec_cfg.max_output_chars,
1159
+ approval_timeout_seconds=exec_cfg.approval_timeout_seconds,
1160
+ )
1161
+
1162
+ # Create agent
1163
+ agent = AgentLoop(
1164
+ bus=bus,
1165
+ provider=provider,
1166
+ workspace=config.workspace_path,
1167
+ model=config.agents.defaults.model,
1168
+ action_temperature=config.agents.defaults.action_temperature,
1169
+ action_tool_retries=config.agents.defaults.action_tool_retries,
1170
+ max_iterations=config.agents.defaults.max_tool_iterations,
1171
+ brave_api_key=config.tools.web.search.api_key or None,
1172
+ context_messages=config.agents.defaults.context_messages,
1173
+ compaction_config=compaction_config,
1174
+ exec_config=exec_config,
1175
+ trello_config=config.integrations.trello,
1176
+ x_config=config.integrations.x,
1177
+ dispatch_config=config.integrations.dispatch,
1178
+ tools_config=config.tools,
1179
+ persona=active_persona,
1180
+ mcp_servers=config.tools.mcp_servers or None,
1181
+ activity_bus=activity_bus,
1182
+ )
1183
+
1184
+ # Multi-agent setup (if agents are configured in config.json)
1185
+ multi_agents = config.agents.agents
1186
+ multi_teams = config.agents.teams
1187
+
1188
+ if multi_agents:
1189
+ from flowly_code.multiagent.router import AgentRouter
1190
+ from flowly_code.multiagent.orchestrator import TeamOrchestrator
1191
+ from flowly_code.multiagent.setup import ensure_agent_directory
1192
+ from flowly_code.agent.tools.delegate import DelegateTool
1193
+
1194
+ ma_router = AgentRouter(multi_agents, multi_teams)
1195
+ ma_orchestrator = TeamOrchestrator(ma_router)
1196
+
1197
+ # Setup agent working directories
1198
+ agents_workspace = config.workspace_path / "agents"
1199
+ for aid, acfg in multi_agents.items():
1200
+ agent_dir = agents_workspace / aid
1201
+ ensure_agent_directory(agent_dir, aid, multi_agents, multi_teams)
1202
+
1203
+ # Register delegate_to tool on main agent
1204
+ delegate_tool = DelegateTool(multi_agents, multi_teams, agents_workspace, bus, activity_bus=activity_bus)
1205
+ agent.tools.register(delegate_tool)
1206
+
1207
+ # Wrap _process_message with multi-agent routing
1208
+ _original_process = agent._process_message
1209
+
1210
+ async def _routed_process(msg):
1211
+ from flowly_code.bus.events import InboundMessage as _IB, OutboundMessage as _OB
1212
+
1213
+ # Update delegate tool context so background results go to the right chat
1214
+ delegate_tool.set_context(msg.channel, msg.chat_id)
1215
+
1216
+ # System messages bypass routing
1217
+ if msg.channel == "system":
1218
+ return await _original_process(msg)
1219
+
1220
+ # Background delegate result — model should summarize, NOT re-delegate
1221
+ if msg.content.startswith("[DELEGATE_RESULT:"):
1222
+ # Temporarily remove delegate_to tool to prevent loops
1223
+ agent.tools.unregister("delegate_to")
1224
+ try:
1225
+ return await _original_process(msg)
1226
+ finally:
1227
+ # Restore the tool for future messages
1228
+ agent.tools.register(delegate_tool)
1229
+
1230
+ # Route @mentions
1231
+ routing = ma_router.route(msg.content)
1232
+
1233
+ if routing.agent_id == "default" or routing.agent_id not in multi_agents:
1234
+ return await _original_process(msg)
1235
+
1236
+ # @mention detected — rewrite message so the main agent uses delegate_to tool
1237
+ # This way the model responds naturally AND the task runs in background
1238
+ msg.content = (
1239
+ f"[SYSTEM: User wants to talk to @{routing.agent_id}. "
1240
+ f"Use the delegate_to tool with agent_id=\"{routing.agent_id}\" "
1241
+ f"and the following message.]\n\n{routing.message}"
1242
+ )
1243
+ return await _original_process(msg)
1244
+
1245
+ agent._process_message = _routed_process
1246
+
1247
+ agent_names = [f"@{aid} ({acfg.name})" for aid, acfg in multi_agents.items()]
1248
+ console.print(f"[green]✓[/green] Multi-agent: {', '.join(agent_names)}")
1249
+ if multi_teams:
1250
+ team_names = [f"@{tid} ({tcfg.name})" for tid, tcfg in multi_teams.items()]
1251
+ console.print(f"[green]✓[/green] Teams: {', '.join(team_names)}")
1252
+
1253
+
1254
+ # Create heartbeat service
1255
+ async def on_heartbeat(prompt: str) -> str:
1256
+ """Execute heartbeat through the agent."""
1257
+ return await agent.process_direct(prompt, session_key="heartbeat")
1258
+
1259
+ heartbeat = HeartbeatService(
1260
+ workspace=config.workspace_path,
1261
+ on_heartbeat=on_heartbeat,
1262
+ interval_s=30 * 60, # 30 minutes
1263
+ enabled=True
1264
+ )
1265
+
1266
+ # Gateway setup (disabled by default; integrated Python plugin is official path)
1267
+ gateway_server = GatewayServer(
1268
+ host=config.gateway.host,
1269
+ port=port,
1270
+ activity_bus=activity_bus,
1271
+ )
1272
+
1273
+ # Create channel manager (handles Telegram, WhatsApp, Discord, Slack)
1274
+ from flowly_code.channels.manager import ChannelManager
1275
+ channels = ChannelManager(config, bus)
1276
+
1277
+ # Set up compact callback for channels
1278
+ async def on_compact(session_key: str, instructions: str | None = None) -> dict:
1279
+ """Handle /compact command from channels."""
1280
+ return await agent.compact_session(session_key, instructions)
1281
+
1282
+ channels.set_compact_callback(on_compact)
1283
+
1284
+ if channels.enabled_channels:
1285
+ console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
1286
+
1287
+ console.print(f"[green]✓[/green] Heartbeat: every 30m")
1288
+ console.print(f"[green]✓[/green] API: http://{config.gateway.host}:{port}")
1289
+
1290
+ async def run():
1291
+ shutdown_event = asyncio.Event()
1292
+
1293
+ def signal_handler():
1294
+ console.print("\n[yellow]Shutting down...[/yellow]")
1295
+ shutdown_event.set()
1296
+
1297
+ if platform.system() == "Windows":
1298
+ # Windows asyncio doesn't support loop.add_signal_handler
1299
+ signal.signal(signal.SIGINT, lambda s, f: signal_handler())
1300
+ signal.signal(signal.SIGTERM, lambda s, f: signal_handler())
1301
+ else:
1302
+ loop = asyncio.get_running_loop()
1303
+ for sig in (signal.SIGINT, signal.SIGTERM):
1304
+ loop.add_signal_handler(sig, signal_handler)
1305
+
1306
+ try:
1307
+ await gateway_server.start()
1308
+ await heartbeat.start()
1309
+
1310
+ # Run until shutdown signal
1311
+ async def run_until_shutdown():
1312
+ await asyncio.gather(
1313
+ agent.run(),
1314
+ channels.start_all(),
1315
+ )
1316
+
1317
+ # Create main task
1318
+ main_task = asyncio.create_task(run_until_shutdown())
1319
+
1320
+ # Wait for either shutdown signal or task completion
1321
+ done, pending = await asyncio.wait(
1322
+ [main_task, asyncio.create_task(shutdown_event.wait())],
1323
+ return_when=asyncio.FIRST_COMPLETED
1324
+ )
1325
+
1326
+ # Cancel pending tasks
1327
+ for task in pending:
1328
+ task.cancel()
1329
+ try:
1330
+ await task
1331
+ except asyncio.CancelledError:
1332
+ pass
1333
+
1334
+ finally:
1335
+ # Graceful shutdown
1336
+ console.print("[dim]Cleaning up...[/dim]")
1337
+ await gateway_server.stop()
1338
+ heartbeat.stop()
1339
+ agent.stop()
1340
+ await channels.stop_all()
1341
+ console.print("[green]✓[/green] Shutdown complete")
1342
+
1343
+ asyncio.run(run())
1344
+
1345
+
1346
+
1347
+
1348
+ # ============================================================================
1349
+ # Agent Commands
1350
+ # ============================================================================
1351
+
1352
+
1353
+ @app.command()
1354
+ def agent(
1355
+ message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
1356
+ session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
1357
+ dispatch_port: int = typer.Option(0, "--dispatch-port", help="Dispatch backend port (overrides config)"),
1358
+ project_id: str = typer.Option("", "--project-id", help="Active Dispatch project ID"),
1359
+ ):
1360
+ """Interact with the agent directly."""
1361
+ from flowly_code.config.loader import load_config, get_data_dir
1362
+ from flowly_code.bus.queue import MessageBus
1363
+ from flowly_code.providers.litellm_provider import LiteLLMProvider
1364
+ from flowly_code.agent.loop import AgentLoop
1365
+
1366
+ config = load_config()
1367
+
1368
+ api_key = config.get_api_key()
1369
+ api_base = config.get_api_base()
1370
+
1371
+ if not api_key:
1372
+ console.print("[red]Error: No API key configured.[/red]")
1373
+ raise typer.Exit(1)
1374
+
1375
+ bus = MessageBus()
1376
+ provider = LiteLLMProvider(
1377
+ api_key=api_key,
1378
+ api_base=api_base,
1379
+ default_model=config.agents.defaults.model
1380
+ )
1381
+
1382
+ # Build compaction config
1383
+ from flowly_code.compaction.types import CompactionConfig, MemoryFlushConfig
1384
+ compaction_cfg = config.agents.defaults.compaction
1385
+ compaction_config = CompactionConfig(
1386
+ mode=compaction_cfg.mode,
1387
+ reserve_tokens_floor=compaction_cfg.reserve_tokens_floor,
1388
+ max_history_share=compaction_cfg.max_history_share,
1389
+ context_window=compaction_cfg.context_window,
1390
+ memory_flush=MemoryFlushConfig(
1391
+ enabled=compaction_cfg.memory_flush.enabled,
1392
+ soft_threshold_tokens=compaction_cfg.memory_flush.soft_threshold_tokens,
1393
+ prompt=compaction_cfg.memory_flush.prompt,
1394
+ system_prompt=compaction_cfg.memory_flush.system_prompt,
1395
+ ),
1396
+ )
1397
+
1398
+ # Build exec config
1399
+ from flowly_code.exec.types import ExecConfig
1400
+ exec_cfg = config.tools.exec
1401
+ exec_config = ExecConfig(
1402
+ enabled=exec_cfg.enabled,
1403
+ security=exec_cfg.security,
1404
+ ask=exec_cfg.ask,
1405
+ timeout_seconds=exec_cfg.timeout_seconds,
1406
+ max_output_chars=exec_cfg.max_output_chars,
1407
+ approval_timeout_seconds=exec_cfg.approval_timeout_seconds,
1408
+ )
1409
+
1410
+ # Build dispatch config (from config file, with CLI overrides)
1411
+ dispatch_cfg = config.integrations.dispatch
1412
+ if dispatch_port > 0:
1413
+ dispatch_cfg.backend_port = dispatch_port
1414
+ dispatch_cfg.enabled = True
1415
+ if project_id:
1416
+ dispatch_cfg.project_id = project_id
1417
+
1418
+ agent_loop = AgentLoop(
1419
+ bus=bus,
1420
+ provider=provider,
1421
+ workspace=config.workspace_path,
1422
+ model=config.agents.defaults.model,
1423
+ action_temperature=config.agents.defaults.action_temperature,
1424
+ action_tool_retries=config.agents.defaults.action_tool_retries,
1425
+ brave_api_key=config.tools.web.search.api_key or None,
1426
+ context_messages=config.agents.defaults.context_messages,
1427
+ compaction_config=compaction_config,
1428
+ exec_config=exec_config,
1429
+ trello_config=config.integrations.trello,
1430
+ x_config=config.integrations.x,
1431
+ dispatch_config=dispatch_cfg,
1432
+ tools_config=config.tools,
1433
+ persona=config.agents.defaults.persona,
1434
+ mcp_servers=config.tools.mcp_servers or None,
1435
+ )
1436
+
1437
+ async def _cli_progress(content: str) -> None:
1438
+ console.print(f" [dim]↳ {content}[/dim]")
1439
+
1440
+ async def handle_compact(instructions: str | None = None) -> None:
1441
+ """Handle /compact command."""
1442
+ console.print("[cyan]⚙️ Compacting conversation history...[/cyan]")
1443
+ result = await agent_loop.compact_session(session_id, instructions)
1444
+ if result["success"]:
1445
+ console.print(
1446
+ f"[green]✓[/green] {result['message']} "
1447
+ f"({result['tokens_before']} → {result['tokens_after']} tokens)"
1448
+ )
1449
+ console.print(f"\n[dim]Summary preview:[/dim]\n{result['summary_preview']}")
1450
+ else:
1451
+ console.print(f"[yellow]{result['message']}[/yellow]")
1452
+
1453
+ if message:
1454
+ # Single message mode - check for /compact
1455
+ if message.strip().startswith("/compact"):
1456
+ parts = message.strip().split(" ", 1)
1457
+ instructions = parts[1] if len(parts) > 1 else None
1458
+ asyncio.run(handle_compact(instructions))
1459
+ else:
1460
+ async def run_once():
1461
+ response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
1462
+ console.print(f"\n{__logo__} {response}")
1463
+ asyncio.run(run_once())
1464
+ else:
1465
+ # Interactive mode
1466
+ console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)")
1467
+ console.print("[dim]Commands: /compact [instructions], /clear, /quit[/dim]\n")
1468
+
1469
+ async def run_interactive():
1470
+ while True:
1471
+ try:
1472
+ user_input = console.input("[bold blue]You:[/bold blue] ")
1473
+ if not user_input.strip():
1474
+ continue
1475
+
1476
+ # Handle slash commands
1477
+ if user_input.strip().startswith("/"):
1478
+ cmd_parts = user_input.strip().split(" ", 1)
1479
+ cmd = cmd_parts[0].lower()
1480
+ args = cmd_parts[1] if len(cmd_parts) > 1 else None
1481
+
1482
+ if cmd == "/compact":
1483
+ await handle_compact(args)
1484
+ continue
1485
+ elif cmd == "/clear":
1486
+ session = agent_loop.sessions.get_or_create(session_id)
1487
+ session.clear()
1488
+ agent_loop.sessions.save(session)
1489
+ console.print("[green]✓[/green] Session cleared")
1490
+ continue
1491
+ elif cmd in ("/quit", "/exit", "/q"):
1492
+ console.print("Goodbye!")
1493
+ break
1494
+ elif cmd == "/help":
1495
+ console.print("\n[bold]Available commands:[/bold]")
1496
+ console.print(" /compact [instructions] - Summarize conversation history")
1497
+ console.print(" /clear - Clear session history")
1498
+ console.print(" /quit - Exit interactive mode")
1499
+ console.print(" /help - Show this help\n")
1500
+ continue
1501
+
1502
+ response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
1503
+ console.print(f"\n{__logo__} {response}\n")
1504
+ except KeyboardInterrupt:
1505
+ console.print("\nGoodbye!")
1506
+ break
1507
+
1508
+ asyncio.run(run_interactive())
1509
+
1510
+
1511
+
1512
+ # ============================================================================
1513
+ # Exec Approvals Commands
1514
+ # ============================================================================
1515
+
1516
+ approvals_app = typer.Typer(help="Manage command execution approvals")
1517
+ app.add_typer(approvals_app, name="approvals")
1518
+
1519
+
1520
+ @approvals_app.command("status")
1521
+ def approvals_status():
1522
+ """Show exec approvals configuration."""
1523
+ from flowly_code.exec.approvals import ExecApprovalStore
1524
+
1525
+ store = ExecApprovalStore()
1526
+ config = store.load()
1527
+
1528
+ console.print("\n[bold cyan]Exec Approvals Configuration[/bold cyan]")
1529
+ console.print("─" * 40)
1530
+ console.print(f"Security: [cyan]{config.security}[/cyan]")
1531
+ console.print(f"Ask mode: [cyan]{config.ask}[/cyan]")
1532
+ console.print(f"Ask fallback: [cyan]{config.ask_fallback}[/cyan]")
1533
+ console.print(f"Allowlist entries: [cyan]{len(config.allowlist)}[/cyan]")
1534
+
1535
+ if config.security == "deny":
1536
+ console.print("\n[yellow]⚠️ Command execution is currently DENIED[/yellow]")
1537
+ console.print("[dim]Run 'flowly approvals set --security allowlist' to enable[/dim]")
1538
+
1539
+
1540
+ @approvals_app.command("set")
1541
+ def approvals_set(
1542
+ security: str = typer.Option(None, "--security", "-s", help="Security mode: deny, allowlist, full"),
1543
+ ask: str = typer.Option(None, "--ask", "-a", help="Ask mode: off, on-miss, always"),
1544
+ ):
1545
+ """Update exec approvals configuration."""
1546
+ from flowly_code.exec.approvals import ExecApprovalStore
1547
+
1548
+ store = ExecApprovalStore()
1549
+ config = store.load()
1550
+
1551
+ if security:
1552
+ if security not in ("deny", "allowlist", "full"):
1553
+ console.print(f"[red]Invalid security mode: {security}[/red]")
1554
+ raise typer.Exit(1)
1555
+ config.security = security
1556
+ console.print(f"[green]✓[/green] Security set to [cyan]{security}[/cyan]")
1557
+
1558
+ if ask:
1559
+ if ask not in ("off", "on-miss", "always"):
1560
+ console.print(f"[red]Invalid ask mode: {ask}[/red]")
1561
+ raise typer.Exit(1)
1562
+ config.ask = ask
1563
+ console.print(f"[green]✓[/green] Ask mode set to [cyan]{ask}[/cyan]")
1564
+
1565
+ store.save()
1566
+
1567
+
1568
+ @approvals_app.command("list")
1569
+ def approvals_list():
1570
+ """List allowlist entries."""
1571
+ from flowly_code.exec.approvals import ExecApprovalStore
1572
+
1573
+ store = ExecApprovalStore()
1574
+ config = store.load()
1575
+
1576
+ if not config.allowlist:
1577
+ console.print("[dim]No allowlist entries.[/dim]")
1578
+ console.print("[dim]Commands will require approval (if ask mode is on-miss or always)[/dim]")
1579
+ return
1580
+
1581
+ table = Table(title="Exec Allowlist")
1582
+ table.add_column("Pattern", style="cyan")
1583
+ table.add_column("Last Used")
1584
+ table.add_column("Command")
1585
+
1586
+ import time
1587
+ for entry in config.allowlist:
1588
+ last_used = ""
1589
+ if entry.last_used_at:
1590
+ last_used = time.strftime("%Y-%m-%d %H:%M", time.localtime(entry.last_used_at / 1000))
1591
+ cmd = entry.last_used_command or ""
1592
+ if len(cmd) > 40:
1593
+ cmd = cmd[:40] + "..."
1594
+ table.add_row(entry.pattern, last_used, cmd)
1595
+
1596
+ console.print(table)
1597
+
1598
+
1599
+ @approvals_app.command("add")
1600
+ def approvals_add(
1601
+ pattern: str = typer.Argument(..., help="Path pattern to allow (supports glob)"),
1602
+ ):
1603
+ """Add a pattern to the allowlist."""
1604
+ from flowly_code.exec.approvals import ExecApprovalStore
1605
+
1606
+ store = ExecApprovalStore()
1607
+ store.load()
1608
+ store.add_to_allowlist(pattern)
1609
+
1610
+ console.print(f"[green]✓[/green] Added [cyan]{pattern}[/cyan] to allowlist")
1611
+
1612
+
1613
+ @approvals_app.command("remove")
1614
+ def approvals_remove(
1615
+ pattern: str = typer.Argument(..., help="Pattern to remove"),
1616
+ ):
1617
+ """Remove a pattern from the allowlist."""
1618
+ from flowly_code.exec.approvals import ExecApprovalStore
1619
+
1620
+ store = ExecApprovalStore()
1621
+ store.load()
1622
+
1623
+ if store.remove_from_allowlist(pattern):
1624
+ console.print(f"[green]✓[/green] Removed [cyan]{pattern}[/cyan] from allowlist")
1625
+ else:
1626
+ console.print(f"[yellow]Pattern not found: {pattern}[/yellow]")
1627
+
1628
+
1629
+ @approvals_app.command("safe-bins")
1630
+ def approvals_safe_bins():
1631
+ """List safe bins that are always allowed."""
1632
+ from flowly_code.exec.safety import DEFAULT_SAFE_BINS
1633
+
1634
+ console.print("\n[bold]Safe Bins (always allowed for stdin operations):[/bold]")
1635
+ for bin_name in sorted(DEFAULT_SAFE_BINS):
1636
+ console.print(f" • {bin_name}")
1637
+ console.print("\n[dim]These commands are allowed without explicit allowlist entry[/dim]")
1638
+ console.print("[dim]when they don't reference files as arguments.[/dim]")
1639
+
1640
+
1641
+ # ============================================================================
1642
+ # Pairing Commands
1643
+ # ============================================================================
1644
+
1645
+ pairing_app = typer.Typer(help="Secure channel pairing")
1646
+ app.add_typer(pairing_app, name="pairing")
1647
+
1648
+
1649
+ @pairing_app.command("list")
1650
+ def pairing_list(
1651
+ channel: str = typer.Argument(..., help="Channel (telegram, whatsapp)"),
1652
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
1653
+ ):
1654
+ """List pending pairing requests."""
1655
+ from flowly_code.pairing import list_pairing_requests
1656
+
1657
+ if channel not in ("telegram", "whatsapp", "discord", "slack"):
1658
+ console.print(f"[red]Invalid channel: {channel}. Use 'telegram', 'whatsapp', 'discord', or 'slack'[/red]")
1659
+ raise typer.Exit(1)
1660
+
1661
+ requests = list_pairing_requests(channel)
1662
+
1663
+ if json_output:
1664
+ import json
1665
+ data = [
1666
+ {
1667
+ "id": r.id,
1668
+ "code": r.code,
1669
+ "created_at": r.created_at,
1670
+ "meta": r.meta,
1671
+ }
1672
+ for r in requests
1673
+ ]
1674
+ console.print(json.dumps({"channel": channel, "requests": data}, indent=2))
1675
+ return
1676
+
1677
+ if not requests:
1678
+ console.print(f"[dim]No pending {channel} pairing requests.[/dim]")
1679
+ return
1680
+
1681
+ table = Table(title=f"Pending {channel.title()} Pairing Requests")
1682
+ table.add_column("Code", style="cyan")
1683
+ table.add_column("User ID")
1684
+ table.add_column("Meta")
1685
+ table.add_column("Requested")
1686
+
1687
+ for r in requests:
1688
+ meta_str = ", ".join(f"{k}={v}" for k, v in r.meta.items()) if r.meta else ""
1689
+ table.add_row(r.code, r.id, meta_str, r.created_at[:19])
1690
+
1691
+ console.print(table)
1692
+
1693
+
1694
+ @pairing_app.command("approve")
1695
+ def pairing_approve(
1696
+ channel: str = typer.Argument(..., help="Channel (telegram, whatsapp)"),
1697
+ code: str = typer.Argument(..., help="Pairing code"),
1698
+ notify: bool = typer.Option(False, "--notify", "-n", help="Notify user on approval"),
1699
+ ):
1700
+ """Approve a pairing code."""
1701
+ from flowly_code.pairing import approve_pairing_code
1702
+ from flowly_code.config.loader import load_config
1703
+
1704
+ if channel not in ("telegram", "whatsapp", "discord", "slack"):
1705
+ console.print(f"[red]Invalid channel: {channel}. Use 'telegram', 'whatsapp', 'discord', or 'slack'[/red]")
1706
+ raise typer.Exit(1)
1707
+
1708
+ approved = approve_pairing_code(channel, code)
1709
+
1710
+ if not approved:
1711
+ console.print(f"[red]No pending pairing request found for code: {code}[/red]")
1712
+ raise typer.Exit(1)
1713
+
1714
+ console.print(f"[green]✓[/green] Approved {channel} sender [cyan]{approved.id}[/cyan]")
1715
+
1716
+ if approved.meta:
1717
+ meta_str = ", ".join(f"{k}={v}" for k, v in approved.meta.items())
1718
+ console.print(f" [dim]({meta_str})[/dim]")
1719
+
1720
+ # Notify user if requested
1721
+ if notify and channel == "telegram":
1722
+ config = load_config()
1723
+ if config.channels.telegram.token:
1724
+ async def send_notification():
1725
+ import httpx
1726
+ token = config.channels.telegram.token
1727
+ url = f"https://api.telegram.org/bot{token}/sendMessage"
1728
+ try:
1729
+ async with httpx.AsyncClient() as client:
1730
+ await client.post(url, json={
1731
+ "chat_id": approved.id,
1732
+ "text": "✅ Access approved! Send a message to start chatting.",
1733
+ })
1734
+ console.print(f"[green]✓[/green] Notification sent")
1735
+ except Exception as e:
1736
+ console.print(f"[yellow]Warning: Could not notify user: {e}[/yellow]")
1737
+
1738
+ asyncio.run(send_notification())
1739
+
1740
+ # Auto-restart gateway if running so it picks up the new allow list
1741
+ config = load_config()
1742
+ ok, _ = _service_health(config.gateway.port)
1743
+ if ok:
1744
+ console.print("[dim]Restarting gateway...[/dim]")
1745
+ try:
1746
+ service_restart(label=DEFAULT_SERVICE_LABEL)
1747
+ except (SystemExit, Exception):
1748
+ console.print("[yellow]Could not auto-restart. Run: flowly service restart[/yellow]")
1749
+
1750
+
1751
+ @pairing_app.command("revoke")
1752
+ def pairing_revoke(
1753
+ channel: str = typer.Argument(..., help="Channel (telegram, whatsapp)"),
1754
+ user_id: str = typer.Argument(..., help="User ID to revoke"),
1755
+ ):
1756
+ """Revoke access for a user."""
1757
+ from flowly_code.pairing import remove_allow_from_entry
1758
+
1759
+ if channel not in ("telegram", "whatsapp", "discord", "slack"):
1760
+ console.print(f"[red]Invalid channel: {channel}. Use 'telegram', 'whatsapp', 'discord', or 'slack'[/red]")
1761
+ raise typer.Exit(1)
1762
+
1763
+ if remove_allow_from_entry(channel, user_id):
1764
+ console.print(f"[green]✓[/green] Revoked access for {user_id}")
1765
+ else:
1766
+ console.print(f"[yellow]User {user_id} was not in the allow list[/yellow]")
1767
+
1768
+
1769
+ @pairing_app.command("allowed")
1770
+ def pairing_allowed(
1771
+ channel: str = typer.Argument(..., help="Channel (telegram, whatsapp)"),
1772
+ ):
1773
+ """List allowed users from pairing store."""
1774
+ from flowly_code.pairing import read_allow_from_store
1775
+
1776
+ if channel not in ("telegram", "whatsapp", "discord", "slack"):
1777
+ console.print(f"[red]Invalid channel: {channel}. Use 'telegram', 'whatsapp', 'discord', or 'slack'[/red]")
1778
+ raise typer.Exit(1)
1779
+
1780
+ allowed = read_allow_from_store(channel)
1781
+
1782
+ if not allowed:
1783
+ console.print(f"[dim]No users in {channel} pairing store.[/dim]")
1784
+ console.print("[dim]Users can also be allowed via config.json allow_from list.[/dim]")
1785
+ return
1786
+
1787
+ console.print(f"[bold]{channel.title()} Allowed Users (from pairing):[/bold]")
1788
+ for user_id in allowed:
1789
+ console.print(f" • {user_id}")
1790
+
1791
+
1792
+ # ============================================================================
1793
+ # Status Commands
1794
+ # ============================================================================
1795
+
1796
+
1797
+ @app.command()
1798
+ def status():
1799
+ """Show flowly status."""
1800
+ from flowly_code.config.loader import load_config, get_config_path
1801
+ from flowly_code.utils.helpers import get_workspace_path
1802
+
1803
+ config_path = get_config_path()
1804
+ workspace = get_workspace_path()
1805
+
1806
+ console.print(f"{__logo__} Nanobot Status\n")
1807
+
1808
+ console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
1809
+ console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
1810
+
1811
+ if config_path.exists():
1812
+ config = load_config()
1813
+ console.print(f"Model: {config.agents.defaults.model}")
1814
+
1815
+ # Check API keys
1816
+ has_openrouter = bool(config.providers.openrouter.api_key)
1817
+ has_anthropic = bool(config.providers.anthropic.api_key)
1818
+ has_openai = bool(config.providers.openai.api_key)
1819
+ has_gemini = bool(config.providers.gemini.api_key)
1820
+ has_vllm = bool(config.providers.vllm.api_base)
1821
+
1822
+ console.print(f"OpenRouter API: {'[green]✓[/green]' if has_openrouter else '[dim]not set[/dim]'}")
1823
+ console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}")
1824
+ console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}")
1825
+ console.print(f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}")
1826
+ vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
1827
+ console.print(f"vLLM/Local: {vllm_status}")
1828
+
1829
+
1830
+ if __name__ == "__main__":
1831
+ app()