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.
- flowly_code/__init__.py +30 -0
- flowly_code/__main__.py +8 -0
- flowly_code/activity/__init__.py +1 -0
- flowly_code/activity/bus.py +91 -0
- flowly_code/activity/events.py +40 -0
- flowly_code/agent/__init__.py +8 -0
- flowly_code/agent/context.py +485 -0
- flowly_code/agent/loop.py +1349 -0
- flowly_code/agent/memory.py +109 -0
- flowly_code/agent/skills.py +259 -0
- flowly_code/agent/subagent.py +249 -0
- flowly_code/agent/tools/__init__.py +6 -0
- flowly_code/agent/tools/base.py +55 -0
- flowly_code/agent/tools/delegate.py +194 -0
- flowly_code/agent/tools/dispatch.py +840 -0
- flowly_code/agent/tools/docker.py +609 -0
- flowly_code/agent/tools/filesystem.py +280 -0
- flowly_code/agent/tools/mcp.py +85 -0
- flowly_code/agent/tools/message.py +235 -0
- flowly_code/agent/tools/registry.py +257 -0
- flowly_code/agent/tools/screenshot.py +444 -0
- flowly_code/agent/tools/shell.py +166 -0
- flowly_code/agent/tools/spawn.py +65 -0
- flowly_code/agent/tools/system.py +917 -0
- flowly_code/agent/tools/trello.py +420 -0
- flowly_code/agent/tools/web.py +139 -0
- flowly_code/agent/tools/x.py +399 -0
- flowly_code/bus/__init__.py +6 -0
- flowly_code/bus/events.py +37 -0
- flowly_code/bus/queue.py +81 -0
- flowly_code/channels/__init__.py +6 -0
- flowly_code/channels/base.py +121 -0
- flowly_code/channels/manager.py +135 -0
- flowly_code/channels/telegram.py +1132 -0
- flowly_code/cli/__init__.py +1 -0
- flowly_code/cli/commands.py +1831 -0
- flowly_code/cli/setup.py +1356 -0
- flowly_code/compaction/__init__.py +39 -0
- flowly_code/compaction/estimator.py +88 -0
- flowly_code/compaction/pruning.py +223 -0
- flowly_code/compaction/service.py +297 -0
- flowly_code/compaction/summarizer.py +384 -0
- flowly_code/compaction/types.py +71 -0
- flowly_code/config/__init__.py +6 -0
- flowly_code/config/loader.py +102 -0
- flowly_code/config/schema.py +324 -0
- flowly_code/exec/__init__.py +39 -0
- flowly_code/exec/approvals.py +288 -0
- flowly_code/exec/executor.py +184 -0
- flowly_code/exec/safety.py +247 -0
- flowly_code/exec/types.py +88 -0
- flowly_code/gateway/__init__.py +5 -0
- flowly_code/gateway/server.py +103 -0
- flowly_code/heartbeat/__init__.py +5 -0
- flowly_code/heartbeat/service.py +130 -0
- flowly_code/multiagent/README.md +248 -0
- flowly_code/multiagent/__init__.py +1 -0
- flowly_code/multiagent/invoke.py +210 -0
- flowly_code/multiagent/orchestrator.py +156 -0
- flowly_code/multiagent/router.py +156 -0
- flowly_code/multiagent/setup.py +171 -0
- flowly_code/pairing/__init__.py +21 -0
- flowly_code/pairing/store.py +343 -0
- flowly_code/providers/__init__.py +6 -0
- flowly_code/providers/base.py +69 -0
- flowly_code/providers/litellm_provider.py +178 -0
- flowly_code/providers/transcription.py +64 -0
- flowly_code/session/__init__.py +5 -0
- flowly_code/session/manager.py +249 -0
- flowly_code/skills/README.md +24 -0
- flowly_code/skills/compact/SKILL.md +27 -0
- flowly_code/skills/github/SKILL.md +48 -0
- flowly_code/skills/skill-creator/SKILL.md +371 -0
- flowly_code/skills/summarize/SKILL.md +67 -0
- flowly_code/skills/tmux/SKILL.md +121 -0
- flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
- flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
- flowly_code/skills/weather/SKILL.md +49 -0
- flowly_code/utils/__init__.py +5 -0
- flowly_code/utils/helpers.py +91 -0
- flowly_code-1.0.0.dist-info/METADATA +724 -0
- flowly_code-1.0.0.dist-info/RECORD +86 -0
- flowly_code-1.0.0.dist-info/WHEEL +4 -0
- flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
- flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
- 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
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()
|