diff --git a/AGENTS.md b/AGENTS.md index adf6984..60cee87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - Consider affected user journeys before changing shared flows; do not introduce regressions, broken links, or worse experiences in adjacent paths - Keep common paths transparent: long-running work must stream progress, surface recoverable errors clearly, and avoid silent stalls. - For context assembly, prefer manifest-driven progressive disclosure over shortcuts or intent-handler sprawl: expose compact capability indexes, selected schemas, and targeted memory only when the current plan needs them. +- Avoid rule-based natural-language fitting by default. Do not add keyword/action-verb/alias enumerations, intent-handler taxonomies, or brittle routing rules when LLM-native capability disclosure, manifests, schemas, protocols, or configuration-driven contracts can solve the problem. If a rule-based method is truly unavoidable for a stable protocol boundary, offline fallback, or safety hard gate, explain the necessity, scope, alternatives, and rollback path to a human and obtain explicit second confirmation before implementation. - Preserve security and audit paths: dangerous actions, file writes, outbound messages, credentials, and path access must flow through the existing policy, approval, redaction, and audit mechanisms. - Maintain backward-compatible migrations for persistent state, configuration, skills, trajectories, sessions, and profile data. - Write unit tests before or alongside the implementation @@ -62,6 +63,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only ## Review Requirements - **Deep review for large changes**: When a change substantially affects architecture, runtime behavior, user flows, persistence, safety, or multiple modules, perform an additional deep review before considering the work complete. +- **Human confirmation for TUI changes**: Any TUI layout or interaction-logic change requires a second human confirmation before it is considered ready. - **Design goal check**: Verify that the implementation actually achieves the intended design goal and is not just a local patch. - **Optimality check**: Evaluate whether the solution is the simplest robust design, avoids unnecessary abstractions, and fits the existing architecture. - **Regression impact check**: Inspect affected modules and user journeys for logic bugs, degraded UX, broken compatibility, slower feedback, weaker diagnostics, or worse failure recovery. @@ -90,7 +92,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - Hardcoded paths, URLs, thresholds without config escape hatch - Chinese comments in source code (English only) - Speculative infrastructure: no hooks or extension points without a concrete consumer -- Shortcut-style natural-language fitting and large intent-handler taxonomies; use stable runtime gates plus capability manifests instead. +- Shortcut-style natural-language fitting and large intent-handler taxonomies; use stable runtime gates plus capability manifests instead. Rule-based keyword/action-verb/alias matching is prohibited by default and requires explicit human second confirmation before implementation when unavoidable. - Bare `except:` clauses — always specify the exception type - `# TODO: implement` stubs — implement or don't commit diff --git a/pyproject.toml b/pyproject.toml index 62d0163..32fefec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ version = {attr = "leapflow.version.__version__"} where = ["src"] include = ["leapflow*"] +[tool.setuptools.package-data] +"leapflow.gateway.action_packs" = ["*.yaml"] + [tool.pytest.ini_options] asyncio_mode = "auto" pythonpath = ["src", "tests"] diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index f9b0813..64caeaa 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -31,6 +31,11 @@ _last_hint: Optional["PredictionCandidate"] = None +def _is_app_command(canonical: str) -> bool: + """Return true only for `/app` or `/app ...`, not `/apple`.""" + return canonical == "app" or canonical.startswith("app ") + + async def _prompt_stop_daemon_on_exit( client: "DaemonClient", settings: Any, @@ -253,6 +258,7 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> handle_model, handle_clear, handle_gateway, + handle_app, ) from leapflow.utils.terminal_io import TerminalIOProvider from leapflow.engine.session import SessionMode @@ -570,6 +576,12 @@ async def handle_input(text: str) -> None: handle_gateway(ctx, console, cmd_args) return + if _is_app_command(canonical): + app_args = cmd_text[len("app"):].strip() + await handle_app(ctx, console, app_args) + _update_status() + return + if canonical == "usage": handle_usage(ctx, console, cmd_args) return @@ -808,6 +820,7 @@ async def cmd_interactive_daemon( from leapflow.cli.commands.registry import completion_entries from leapflow.cli.commands.router import CommandRouter, render_command_result from leapflow.cli.commands.slash_handlers import ( + render_app_payload, render_model_payload, render_tools_payload, render_usage_payload, @@ -1155,6 +1168,18 @@ async def handle_input(text: str) -> None: return render_model_payload(console, payload) return + if _is_app_command(canonical): + app_args = invocation.text[len("app"):].strip() + try: + payload = await bridge.call( + lambda current_client: current_client.app_command(app_args), + description="app command", + ) + except Exception as exc: + console.warning(f"App Connector unavailable: {exc}") + return + render_app_payload(console, payload) + return if canonical == "run": if not cmd_args: console.warning("Usage: /run ") diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index 2d5245a..f587742 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -117,6 +117,16 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: # Gateway CommandDef("gateway", "Show connected platforms and gateway status", "Gateway", effect=CommandEffect.EXTERNAL), + # App Connector + CommandDef("app", "List supported external apps or open an app setup guide", "App Connector", args_hint="[platform]", supports_daemon=True), + CommandDef("app list", "List supported external apps", "App Connector", supports_daemon=True), + CommandDef("app status", "Show App Connector status", "App Connector", args_hint="[platform]", supports_daemon=True), + CommandDef("app connect", "Connect a supported external app", "App Connector", args_hint=" [--option value]", effect=CommandEffect.EXTERNAL, execution=CommandExecution.SHORT_OPERATION), + CommandDef("app disconnect", "Disconnect an external app but keep configuration", "App Connector", args_hint="", effect=CommandEffect.EXTERNAL, execution=CommandExecution.SHORT_OPERATION), + CommandDef("app remove", "Remove an app configuration", "App Connector", args_hint="", effect=CommandEffect.DESTRUCTIVE, execution=CommandExecution.SHORT_OPERATION), + CommandDef("app events", "Inspect or control an app event source", "App Connector", args_hint="[status|start|stop] ", effect=CommandEffect.EXTERNAL, execution=CommandExecution.SHORT_OPERATION), + CommandDef("app actions", "List App Connector action domains", "App Connector", args_hint="", supports_daemon=True), + # Scheduler CommandDef("arm", "Schedule a skill for timed execution", "Scheduler", args_hint=" "), CommandDef("tasks", "List scheduled tasks", "Scheduler"), diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index bbf2161..6233115 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +import shlex from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -322,6 +323,416 @@ def handle_gateway(ctx: "Context", console: "LeapConsole", args: str) -> None: )) +def _app_usage() -> dict[str, Any]: + return { + "ok": False, + "error": "Usage: /app [platform] | /app status [platform] | /app connect [--option value] | /app disconnect | /app remove | /app events [status|start|stop] | /app actions ", + "next_actions": ("/app", "/app ", "/app status "), + } + + +def _parse_app_options(tokens: list[str]) -> tuple[dict[str, str], str]: + options: dict[str, str] = {} + index = 0 + while index < len(tokens): + token = tokens[index] + if not token.startswith("--") or token == "--": + return {}, f"Unexpected argument: {token}" + key_value = token[2:] + if "=" in key_value: + key, value = key_value.split("=", 1) + if not key: + return {}, f"Invalid option: {token}" + options[key] = value + index += 1 + continue + if index + 1 >= len(tokens): + return {}, f"Missing value for option: {token}" + key = key_value + if not key: + return {}, f"Invalid option: {token}" + options[key] = tokens[index + 1] + index += 2 + return options, "" + + +def _parse_app_params(args: str) -> dict[str, Any]: + try: + tokens = shlex.split(args) + except ValueError as exc: + return {"ok": False, "error": f"Invalid /app arguments: {exc}"} + + if not tokens or tokens[0].lower() == "list": + if len(tokens) > 1: + return _app_usage() + return {"ok": True, "params": {"action": "list"}, "view": "list"} + + head = tokens[0].lower() + if head == "status": + if len(tokens) > 2: + return _app_usage() + params: dict[str, Any] = {"action": "status"} + if len(tokens) == 2: + params["platform"] = tokens[1].lower() + return {"ok": True, "params": params, "view": "status"} + + if head == "connect": + if len(tokens) < 2: + return _app_usage() + options, error = _parse_app_options(tokens[2:]) + if error: + return { + "ok": False, + "error": error, + "next_actions": ("/app connect --