flowai-cli 0.2.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.
- flowai/__init__.py +72 -0
- flowai/__main__.py +8 -0
- flowai/agents/__init__.py +21 -0
- flowai/agents/base.py +61 -0
- flowai/agents/patterns/__init__.py +11 -0
- flowai/agents/patterns/debate.py +79 -0
- flowai/agents/patterns/reflection.py +74 -0
- flowai/agents/specialized.py +87 -0
- flowai/agents/standard.py +176 -0
- flowai/cli/__init__.py +5 -0
- flowai/cli/main.py +562 -0
- flowai/cli/ui.py +98 -0
- flowai/config/__init__.py +5 -0
- flowai/config/settings.py +204 -0
- flowai/errors.py +30 -0
- flowai/mcp/__init__.py +5 -0
- flowai/mcp/client.py +77 -0
- flowai/memory/__init__.py +16 -0
- flowai/memory/base.py +45 -0
- flowai/memory/chroma.py +97 -0
- flowai/memory/in_memory.py +65 -0
- flowai/memory/qdrant.py +104 -0
- flowai/observability/__init__.py +15 -0
- flowai/observability/events.py +45 -0
- flowai/observability/opentelemetry.py +56 -0
- flowai/observability/tracer.py +80 -0
- flowai/plugins/__init__.py +5 -0
- flowai/plugins/loader.py +31 -0
- flowai/providers/__init__.py +35 -0
- flowai/providers/anthropic.py +268 -0
- flowai/providers/base.py +180 -0
- flowai/providers/gemini.py +230 -0
- flowai/providers/nvidia.py +57 -0
- flowai/providers/ollama.py +237 -0
- flowai/providers/openai.py +274 -0
- flowai/providers/registry.py +50 -0
- flowai/runtime/__init__.py +5 -0
- flowai/runtime/session.py +76 -0
- flowai/server/__init__.py +5 -0
- flowai/server/app.py +133 -0
- flowai/tools/__init__.py +39 -0
- flowai/tools/base.py +56 -0
- flowai/tools/builtin.py +128 -0
- flowai/tools/decorator.py +103 -0
- flowai/tools/files.py +197 -0
- flowai/tools/registry.py +84 -0
- flowai/tools/web.py +94 -0
- flowai/workflow/__init__.py +15 -0
- flowai/workflow/checkpoint.py +73 -0
- flowai/workflow/engine.py +144 -0
- flowai/workflow/state.py +43 -0
- flowai_cli-0.2.0.dist-info/METADATA +223 -0
- flowai_cli-0.2.0.dist-info/RECORD +56 -0
- flowai_cli-0.2.0.dist-info/WHEEL +4 -0
- flowai_cli-0.2.0.dist-info/entry_points.txt +2 -0
- flowai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
flowai/cli/main.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""The `flowai` command.
|
|
2
|
+
|
|
3
|
+
flowai start an interactive session (runs setup first if needed)
|
|
4
|
+
flowai "fix the failing test" run one prompt and exit
|
|
5
|
+
flowai configure | init | run | chat | serve | inspect | tools
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from flowai import __version__
|
|
19
|
+
from flowai.agents.standard import Agent
|
|
20
|
+
from flowai.cli import ui
|
|
21
|
+
from flowai.config.settings import (
|
|
22
|
+
GLOBAL_CONFIG_DIR,
|
|
23
|
+
GLOBAL_CONFIG_PATH,
|
|
24
|
+
PROVIDER_DEFAULT_MODEL,
|
|
25
|
+
PROVIDER_KEY_ENV,
|
|
26
|
+
Settings,
|
|
27
|
+
)
|
|
28
|
+
from flowai.errors import FlowAIError
|
|
29
|
+
from flowai.providers.registry import ProviderRegistry, get_provider
|
|
30
|
+
from flowai.tools.builtin import default_system_prompt, default_toolset
|
|
31
|
+
from flowai.tools.registry import ToolRegistry
|
|
32
|
+
|
|
33
|
+
SUBCOMMANDS = {"configure", "init", "run", "chat", "serve", "inspect", "tools"}
|
|
34
|
+
|
|
35
|
+
PROVIDER_MENU = [
|
|
36
|
+
("anthropic", "Anthropic (Claude)"),
|
|
37
|
+
("openai", "OpenAI (GPT-4o, o-series)"),
|
|
38
|
+
("nvidia", "NVIDIA API Catalog (DeepSeek-R1, Llama 3.1)"),
|
|
39
|
+
("gemini", "Google Gemini"),
|
|
40
|
+
("openrouter", "OpenRouter (many models, one key)"),
|
|
41
|
+
("ollama", "Ollama (local, no key)"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
CHAT_HELP = """Commands:
|
|
45
|
+
/help show this help
|
|
46
|
+
/model <name> switch model, keep the conversation
|
|
47
|
+
/provider <name> [m] switch provider (and optionally model)
|
|
48
|
+
/tools list the tools the agent can use
|
|
49
|
+
/cost tokens used and estimated cost this session
|
|
50
|
+
/new start a fresh session
|
|
51
|
+
/reset clear the conversation, keep the session id
|
|
52
|
+
/yolo stop asking before shell/python tools run
|
|
53
|
+
/exit quit (also: exit, quit, Ctrl-D)"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------- building blocks
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Approver:
|
|
60
|
+
"""Asks before approval-gated tools run; remembers 'always' per tool."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, mode: str = "ask"):
|
|
63
|
+
self.mode = mode
|
|
64
|
+
self.always: set[str] = set()
|
|
65
|
+
|
|
66
|
+
def __call__(self, tool_name: str, arguments: dict) -> bool:
|
|
67
|
+
if self.mode == "auto" or tool_name in self.always:
|
|
68
|
+
return True
|
|
69
|
+
if not sys.stdin.isatty():
|
|
70
|
+
# Nobody can answer a prompt, so refuse rather than hang or guess.
|
|
71
|
+
ui.warn(f" Refused {tool_name}: no terminal to ask for approval (use --yolo to allow).")
|
|
72
|
+
return False
|
|
73
|
+
verdict = ui.ask_approval(tool_name, arguments)
|
|
74
|
+
if verdict == "always":
|
|
75
|
+
self.always.add(tool_name)
|
|
76
|
+
return verdict in ("yes", "always")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_mcp_tools(cfg: Settings) -> list:
|
|
80
|
+
"""Start each configured MCP server and borrow its tools. Failures are reported, not fatal."""
|
|
81
|
+
servers = {name: s for name, s in cfg.mcp_servers.items() if s.enabled}
|
|
82
|
+
if not servers:
|
|
83
|
+
return []
|
|
84
|
+
from flowai.mcp.client import MCPClient
|
|
85
|
+
|
|
86
|
+
tools: list = []
|
|
87
|
+
for name, server in servers.items():
|
|
88
|
+
try:
|
|
89
|
+
client = MCPClient(server.command, server.args, server.env)
|
|
90
|
+
found = asyncio.run(client.connect_and_discover_tools())
|
|
91
|
+
tools.extend(found)
|
|
92
|
+
ui.info(f" MCP '{name}': {len(found)} tools")
|
|
93
|
+
except Exception as exc: # a broken server must not stop the session
|
|
94
|
+
ui.warn(f" MCP '{name}' failed to start: {exc}")
|
|
95
|
+
return tools
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_agent(args: argparse.Namespace, cfg: Settings, name: str = "FlowAI") -> tuple[Agent, Approver]:
|
|
99
|
+
"""One agent, wired the same way for `run` and `chat`."""
|
|
100
|
+
if getattr(args, "workspace", None):
|
|
101
|
+
os.environ["FLOWAI_WORKSPACE"] = str(Path(args.workspace).resolve())
|
|
102
|
+
|
|
103
|
+
provider_name = getattr(args, "provider", None) or cfg.provider
|
|
104
|
+
model = getattr(args, "model", None) or (
|
|
105
|
+
cfg.model if provider_name == cfg.provider else PROVIDER_DEFAULT_MODEL.get(provider_name, cfg.model)
|
|
106
|
+
)
|
|
107
|
+
provider = get_provider(provider_name, model=model, **cfg.provider_options(provider_name))
|
|
108
|
+
|
|
109
|
+
approver = Approver("auto" if getattr(args, "yolo", False) or cfg.approval == "auto" else "ask")
|
|
110
|
+
tools = ToolRegistry([] if getattr(args, "no_tools", False) else default_toolset(), approver=approver)
|
|
111
|
+
if not getattr(args, "no_tools", False):
|
|
112
|
+
for t in load_mcp_tools(cfg):
|
|
113
|
+
tools.register(t)
|
|
114
|
+
|
|
115
|
+
agent = Agent(
|
|
116
|
+
name=name,
|
|
117
|
+
provider=provider,
|
|
118
|
+
model=model,
|
|
119
|
+
system_prompt=default_system_prompt(),
|
|
120
|
+
tools=tools,
|
|
121
|
+
max_iterations=getattr(args, "max_iterations", None) or cfg.max_iterations,
|
|
122
|
+
temperature=cfg.temperature,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def show(event):
|
|
126
|
+
if event.event == "tool_start":
|
|
127
|
+
ui.tool_start(event.data["tool"], event.data.get("arguments", {}))
|
|
128
|
+
elif event.event == "tool_end":
|
|
129
|
+
ui.tool_end(event.data["tool"], event.data.get("result", ""))
|
|
130
|
+
|
|
131
|
+
agent.event_bus.subscribe(show)
|
|
132
|
+
return agent, approver
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def ensure_configured(cfg: Settings, provider: str | None = None) -> bool:
|
|
136
|
+
"""Tell the user how to fix a missing key instead of failing on the first request."""
|
|
137
|
+
name = provider or cfg.provider
|
|
138
|
+
if cfg.has_credentials(name):
|
|
139
|
+
return True
|
|
140
|
+
env_name = PROVIDER_KEY_ENV.get(name, "an API key")
|
|
141
|
+
ui.error(f"no API key found for provider '{name}'.")
|
|
142
|
+
print(f" Run 'flowai configure' to save one, or set {env_name} in your environment.")
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def stream_reply(agent: Agent, prompt: str) -> None:
|
|
147
|
+
print(ui.paint(f"\n{agent.name}: ", ui.BOLD, ui.GREEN), end="", flush=True)
|
|
148
|
+
async for chunk in agent.stream(prompt):
|
|
149
|
+
if chunk.text_delta:
|
|
150
|
+
print(chunk.text_delta, end="", flush=True)
|
|
151
|
+
print()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------- commands
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cli_configure(args: argparse.Namespace) -> int:
|
|
158
|
+
"""Choose a provider, model and key once; saved to ~/.flowai/config.json."""
|
|
159
|
+
current: dict = {}
|
|
160
|
+
if GLOBAL_CONFIG_PATH.exists():
|
|
161
|
+
try:
|
|
162
|
+
current = json.loads(GLOBAL_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
163
|
+
except (OSError, json.JSONDecodeError):
|
|
164
|
+
current = {}
|
|
165
|
+
|
|
166
|
+
provider = args.provider
|
|
167
|
+
if not provider:
|
|
168
|
+
print(ui.banner([" FlowAI setup: pick a provider and paste your API key"]))
|
|
169
|
+
for i, (_, label) in enumerate(PROVIDER_MENU, 1):
|
|
170
|
+
print(f" {i}. {label}")
|
|
171
|
+
choice = input("\nChoice [1-6] (default 1): ").strip() or "1"
|
|
172
|
+
if not choice.isdigit() or not 1 <= int(choice) <= len(PROVIDER_MENU):
|
|
173
|
+
ui.error(f"'{choice}' is not one of the options.")
|
|
174
|
+
return 1
|
|
175
|
+
provider = PROVIDER_MENU[int(choice) - 1][0]
|
|
176
|
+
provider = provider.lower()
|
|
177
|
+
if provider not in PROVIDER_DEFAULT_MODEL:
|
|
178
|
+
ui.error(f"unknown provider '{provider}'. Choose one of: {', '.join(PROVIDER_DEFAULT_MODEL)}")
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
api_key = args.key
|
|
182
|
+
env_name = PROVIDER_KEY_ENV.get(provider)
|
|
183
|
+
if env_name and not api_key:
|
|
184
|
+
existing = current.get(env_name) or os.getenv(env_name)
|
|
185
|
+
hint = " (press Enter to keep the current one)" if existing else ""
|
|
186
|
+
try:
|
|
187
|
+
import getpass
|
|
188
|
+
api_key = getpass.getpass(f"{provider} API key{hint}: ").strip()
|
|
189
|
+
except (EOFError, KeyboardInterrupt):
|
|
190
|
+
print()
|
|
191
|
+
return 1
|
|
192
|
+
if not api_key and not existing:
|
|
193
|
+
ui.error("an API key is required for this provider.")
|
|
194
|
+
return 1
|
|
195
|
+
|
|
196
|
+
model = args.model
|
|
197
|
+
if not model:
|
|
198
|
+
default_model = PROVIDER_DEFAULT_MODEL[provider]
|
|
199
|
+
if args.provider: # non-interactive: take the default
|
|
200
|
+
model = default_model
|
|
201
|
+
else:
|
|
202
|
+
model = input(f"Model (Enter for {default_model}): ").strip() or default_model
|
|
203
|
+
|
|
204
|
+
current["provider"] = provider
|
|
205
|
+
current["model"] = model
|
|
206
|
+
if api_key and env_name:
|
|
207
|
+
current[env_name] = api_key
|
|
208
|
+
|
|
209
|
+
GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
GLOBAL_CONFIG_PATH.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
|
211
|
+
try:
|
|
212
|
+
os.chmod(GLOBAL_CONFIG_PATH, 0o600) # the file holds a secret
|
|
213
|
+
except OSError:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
print(f"\nSaved to {GLOBAL_CONFIG_PATH}")
|
|
217
|
+
print(f" provider: {provider}\n model: {model}")
|
|
218
|
+
print("\nStart with: flowai")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cli_init(args: argparse.Namespace) -> int:
|
|
223
|
+
"""Scaffold a project with a flowai.yaml and a starter agent."""
|
|
224
|
+
target_dir = Path(args.name)
|
|
225
|
+
if (target_dir / "flowai.yaml").exists():
|
|
226
|
+
ui.error(f"{target_dir / 'flowai.yaml'} already exists; not overwriting it.")
|
|
227
|
+
return 1
|
|
228
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
cfg = Settings.load()
|
|
230
|
+
|
|
231
|
+
(target_dir / "flowai.yaml").write_text(
|
|
232
|
+
f"""# FlowAI project configuration. Values here override ~/.flowai/config.json.
|
|
233
|
+
project_name: "{target_dir.resolve().name}"
|
|
234
|
+
provider: "{cfg.provider}"
|
|
235
|
+
model: "{cfg.model}"
|
|
236
|
+
temperature: 0.7
|
|
237
|
+
max_iterations: 10
|
|
238
|
+
|
|
239
|
+
# "ask" confirms before shell/python tools run; "auto" never asks.
|
|
240
|
+
approval: "ask"
|
|
241
|
+
|
|
242
|
+
# Tools from MCP servers (needs: pip install 'flowai-cli[mcp]').
|
|
243
|
+
# mcp_servers:
|
|
244
|
+
# filesystem:
|
|
245
|
+
# command: "npx"
|
|
246
|
+
# args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
247
|
+
|
|
248
|
+
server:
|
|
249
|
+
host: "127.0.0.1"
|
|
250
|
+
port: 8000
|
|
251
|
+
""",
|
|
252
|
+
encoding="utf-8",
|
|
253
|
+
)
|
|
254
|
+
if not (target_dir / "main.py").exists():
|
|
255
|
+
(target_dir / "main.py").write_text(
|
|
256
|
+
'''"""A starter FlowAI agent with one custom tool."""
|
|
257
|
+
|
|
258
|
+
import asyncio
|
|
259
|
+
|
|
260
|
+
from flowai import Agent, Settings, tool
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@tool
|
|
264
|
+
def greet(name: str) -> str:
|
|
265
|
+
"""Greet someone by name."""
|
|
266
|
+
return f"Hello, {name}!"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
cfg = Settings.load()
|
|
270
|
+
agent = Agent(name="Assistant", provider=cfg.provider, model=cfg.model, tools=[greet])
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
print(asyncio.run(agent.run("Use the greet tool to say hi to Alice.")))
|
|
274
|
+
''',
|
|
275
|
+
encoding="utf-8",
|
|
276
|
+
)
|
|
277
|
+
print(f"Created a FlowAI project in {target_dir}")
|
|
278
|
+
print(f" cd {args.name}\n flowai")
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cli_run(args: argparse.Namespace) -> int:
|
|
283
|
+
"""Run one prompt to completion and print the answer."""
|
|
284
|
+
cfg = Settings.load()
|
|
285
|
+
if not ensure_configured(cfg, args.provider):
|
|
286
|
+
return 1
|
|
287
|
+
prompt = " ".join(args.prompt).strip()
|
|
288
|
+
if not prompt and not sys.stdin.isatty():
|
|
289
|
+
prompt = sys.stdin.read().strip()
|
|
290
|
+
if not prompt:
|
|
291
|
+
ui.error("no prompt given.")
|
|
292
|
+
return 1
|
|
293
|
+
|
|
294
|
+
agent, _ = build_agent(args, cfg)
|
|
295
|
+
if args.quiet:
|
|
296
|
+
print(asyncio.run(agent.run(prompt)))
|
|
297
|
+
else:
|
|
298
|
+
asyncio.run(stream_reply(agent, prompt))
|
|
299
|
+
if args.cost:
|
|
300
|
+
s = agent.tracer.summary()
|
|
301
|
+
ui.info(f"\n{s['total_tokens']} tokens, est. ${s['total_cost_usd']:.4f}")
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cli_chat(args: argparse.Namespace) -> int:
|
|
306
|
+
"""Interactive session; the conversation is saved after every turn."""
|
|
307
|
+
from flowai.runtime.session import SessionStore
|
|
308
|
+
|
|
309
|
+
cfg = Settings.load()
|
|
310
|
+
if not cfg.has_credentials(args.provider) and not args.provider and sys.stdin.isatty():
|
|
311
|
+
ui.warn("FlowAI is not set up yet. Let's do that now.\n")
|
|
312
|
+
if cli_configure(argparse.Namespace(provider=None, key=None, model=None)) != 0:
|
|
313
|
+
return 1
|
|
314
|
+
cfg = Settings.load()
|
|
315
|
+
if not ensure_configured(cfg, args.provider):
|
|
316
|
+
return 1
|
|
317
|
+
|
|
318
|
+
store = SessionStore()
|
|
319
|
+
initial = []
|
|
320
|
+
if args.session:
|
|
321
|
+
session_id = args.session
|
|
322
|
+
initial = store.load(session_id)
|
|
323
|
+
elif args.resume:
|
|
324
|
+
session_id = store.get_latest_session_id() or f"ses_{uuid.uuid4().hex[:8]}"
|
|
325
|
+
initial = store.load(session_id)
|
|
326
|
+
else:
|
|
327
|
+
session_id = f"ses_{uuid.uuid4().hex[:8]}"
|
|
328
|
+
|
|
329
|
+
agent, approver = build_agent(args, cfg)
|
|
330
|
+
if initial:
|
|
331
|
+
agent.messages = initial
|
|
332
|
+
|
|
333
|
+
workspace = os.getenv("FLOWAI_WORKSPACE") or os.getcwd()
|
|
334
|
+
lines = [
|
|
335
|
+
f" FlowAI {__version__} {agent.provider.provider_name} / {agent.model}",
|
|
336
|
+
f" workspace: {workspace}",
|
|
337
|
+
f" session: {session_id}" + (f" (resumed, {len(initial)} messages)" if initial else ""),
|
|
338
|
+
" /help for commands, /exit to quit",
|
|
339
|
+
]
|
|
340
|
+
if approver.mode == "auto":
|
|
341
|
+
lines.append(" approvals: off (--yolo). Shell and python tools run without asking.")
|
|
342
|
+
print(ui.banner(lines))
|
|
343
|
+
|
|
344
|
+
def handle_command(line: str) -> bool:
|
|
345
|
+
"""Run a slash command. Returns False when the session should end."""
|
|
346
|
+
nonlocal session_id
|
|
347
|
+
cmd, _, rest = line.partition(" ")
|
|
348
|
+
rest = rest.strip()
|
|
349
|
+
if cmd in ("/exit", "/quit"):
|
|
350
|
+
return False
|
|
351
|
+
if cmd == "/help":
|
|
352
|
+
print(CHAT_HELP)
|
|
353
|
+
elif cmd == "/model" and rest:
|
|
354
|
+
agent.switch_model(rest)
|
|
355
|
+
ui.info(f"model -> {rest} (conversation kept)")
|
|
356
|
+
elif cmd == "/provider" and rest:
|
|
357
|
+
name, _, model = rest.partition(" ")
|
|
358
|
+
model = model.strip() or PROVIDER_DEFAULT_MODEL.get(name, agent.model)
|
|
359
|
+
try:
|
|
360
|
+
ProviderRegistry.get(name)
|
|
361
|
+
except ValueError as exc:
|
|
362
|
+
ui.error(str(exc))
|
|
363
|
+
return True
|
|
364
|
+
if not ensure_configured(cfg, name):
|
|
365
|
+
return True
|
|
366
|
+
agent.switch_model(model, provider=get_provider(name, model=model, **cfg.provider_options(name)))
|
|
367
|
+
ui.info(f"provider -> {name} / {model} (conversation kept)")
|
|
368
|
+
elif cmd == "/tools":
|
|
369
|
+
for t in agent.tools.list_tools():
|
|
370
|
+
gate = " (asks first)" if t.requires_approval and approver.mode == "ask" else ""
|
|
371
|
+
print(f" {t.name}{gate}: {t.description}")
|
|
372
|
+
elif cmd == "/cost":
|
|
373
|
+
s = agent.tracer.summary()
|
|
374
|
+
ui.info(f"{s['total_tokens']} tokens, est. ${s['total_cost_usd']:.4f}")
|
|
375
|
+
elif cmd == "/reset":
|
|
376
|
+
agent.reset_session()
|
|
377
|
+
store.save(session_id, agent.messages)
|
|
378
|
+
ui.info("conversation cleared.")
|
|
379
|
+
elif cmd == "/new":
|
|
380
|
+
agent.reset_session()
|
|
381
|
+
session_id = f"ses_{uuid.uuid4().hex[:8]}"
|
|
382
|
+
ui.info(f"new session {session_id}")
|
|
383
|
+
elif cmd == "/yolo":
|
|
384
|
+
approver.mode = "auto"
|
|
385
|
+
ui.warn("approvals off for this session.")
|
|
386
|
+
else:
|
|
387
|
+
ui.warn(f"unknown command '{line}'. Try /help.")
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
while True:
|
|
391
|
+
try:
|
|
392
|
+
line = input(ui.paint("\n> ", ui.BOLD)).strip()
|
|
393
|
+
except (EOFError, KeyboardInterrupt):
|
|
394
|
+
print()
|
|
395
|
+
break
|
|
396
|
+
if not line:
|
|
397
|
+
continue
|
|
398
|
+
if line.lower() in ("exit", "quit"):
|
|
399
|
+
break
|
|
400
|
+
if line.startswith("/"):
|
|
401
|
+
if not handle_command(line):
|
|
402
|
+
break
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
asyncio.run(stream_reply(agent, line))
|
|
407
|
+
except KeyboardInterrupt:
|
|
408
|
+
ui.warn("\n(interrupted)")
|
|
409
|
+
except FlowAIError as exc:
|
|
410
|
+
ui.error(str(exc))
|
|
411
|
+
# Drop the unanswered prompt so the next turn is not confused by it.
|
|
412
|
+
if agent.messages and agent.messages[-1].role == "user":
|
|
413
|
+
agent.messages.pop()
|
|
414
|
+
store.save(session_id, agent.messages)
|
|
415
|
+
|
|
416
|
+
print(f"Session saved: {session_id} (resume with: flowai chat --resume)")
|
|
417
|
+
return 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def cli_serve(args: argparse.Namespace) -> int:
|
|
421
|
+
"""Run the HTTP API."""
|
|
422
|
+
try:
|
|
423
|
+
import uvicorn
|
|
424
|
+
|
|
425
|
+
from flowai.server.app import create_app
|
|
426
|
+
except ImportError:
|
|
427
|
+
ui.error("the server needs extra packages. Install them with: pip install 'flowai-cli[server]'")
|
|
428
|
+
return 1
|
|
429
|
+
|
|
430
|
+
cfg = Settings.load()
|
|
431
|
+
host = args.host or cfg.server.host
|
|
432
|
+
port = args.port or cfg.server.port
|
|
433
|
+
if host not in ("127.0.0.1", "localhost"):
|
|
434
|
+
ui.warn(f"Binding to {host}: anyone who can reach this port can drive an agent with shell access.")
|
|
435
|
+
print(f"FlowAI server on http://{host}:{port}")
|
|
436
|
+
uvicorn.run(create_app(cfg), host=host, port=port)
|
|
437
|
+
return 0
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def cli_inspect(args: argparse.Namespace) -> int:
|
|
441
|
+
"""Show saved sessions and workflow checkpoints."""
|
|
442
|
+
from flowai.runtime.session import SessionStore
|
|
443
|
+
|
|
444
|
+
cfg = Settings.load()
|
|
445
|
+
print(ui.banner([f" FlowAI {__version__} {cfg.provider} / {cfg.model}"]))
|
|
446
|
+
print(f"Config: {GLOBAL_CONFIG_PATH if GLOBAL_CONFIG_PATH.exists() else '(not configured: run flowai configure)'}")
|
|
447
|
+
print(f"Key for {cfg.provider}: {'found' if cfg.has_credentials() else 'MISSING'}")
|
|
448
|
+
|
|
449
|
+
store = SessionStore()
|
|
450
|
+
sessions = store.list_sessions()
|
|
451
|
+
print(f"\nSessions ({store.storage_dir}):")
|
|
452
|
+
if not sessions:
|
|
453
|
+
print(" (none yet)")
|
|
454
|
+
for s in sessions[:10]:
|
|
455
|
+
print(f" {s['session_id']:<15} {s['turns']:>4} messages")
|
|
456
|
+
|
|
457
|
+
checkpoint_dir = Path(".flowai/checkpoints")
|
|
458
|
+
files = sorted(checkpoint_dir.glob("*.json")) if checkpoint_dir.exists() else []
|
|
459
|
+
print(f"\nWorkflow checkpoints ({checkpoint_dir}):")
|
|
460
|
+
if not files:
|
|
461
|
+
print(" (none)")
|
|
462
|
+
for f in files[:10]:
|
|
463
|
+
print(f" {f.name}")
|
|
464
|
+
return 0
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def cli_tools(args: argparse.Namespace) -> int:
|
|
468
|
+
"""List the built-in tools."""
|
|
469
|
+
for t in default_toolset():
|
|
470
|
+
gate = " [asks first]" if t.requires_approval else ""
|
|
471
|
+
print(f"{t.name}{gate}\n {t.description}")
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# ---------------------------------------------------------------------- parser
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _add_agent_options(p: argparse.ArgumentParser) -> None:
|
|
479
|
+
p.add_argument("--provider", help="provider for this run (anthropic, openai, nvidia, gemini, openrouter, ollama)")
|
|
480
|
+
p.add_argument("--model", help="model identifier for this run")
|
|
481
|
+
p.add_argument("-w", "--workspace", help="directory the agent may read and change (default: current)")
|
|
482
|
+
p.add_argument("--yolo", action="store_true", help="never ask before shell/python tools run")
|
|
483
|
+
p.add_argument("--no-tools", action="store_true", help="plain chat, no tools")
|
|
484
|
+
p.add_argument("--max-iterations", type=int, help="cap on tool-calling rounds per prompt")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
488
|
+
parser = argparse.ArgumentParser(
|
|
489
|
+
prog="flowai",
|
|
490
|
+
description="FlowAI: a terminal AI agent. Run 'flowai' to chat, or 'flowai \"<prompt>\"' for one task.",
|
|
491
|
+
)
|
|
492
|
+
parser.add_argument("--version", action="version", version=f"flowai {__version__}")
|
|
493
|
+
sub = parser.add_subparsers(dest="command")
|
|
494
|
+
|
|
495
|
+
p = sub.add_parser("configure", help="choose provider, model and API key")
|
|
496
|
+
p.add_argument("--provider", help="provider name")
|
|
497
|
+
p.add_argument("--key", help="API key (omit to be prompted without echo)")
|
|
498
|
+
p.add_argument("--model", help="default model")
|
|
499
|
+
p.set_defaults(func=cli_configure)
|
|
500
|
+
|
|
501
|
+
p = sub.add_parser("init", help="scaffold a project with flowai.yaml")
|
|
502
|
+
p.add_argument("name", nargs="?", default=".", help="directory (default: current)")
|
|
503
|
+
p.set_defaults(func=cli_init)
|
|
504
|
+
|
|
505
|
+
p = sub.add_parser("run", help="run one prompt and exit")
|
|
506
|
+
p.add_argument("prompt", nargs="*", help="the prompt (or pipe it on stdin)")
|
|
507
|
+
p.add_argument("-q", "--quiet", action="store_true", help="print only the final answer")
|
|
508
|
+
p.add_argument("--cost", action="store_true", help="print token usage afterwards")
|
|
509
|
+
_add_agent_options(p)
|
|
510
|
+
p.set_defaults(func=cli_run)
|
|
511
|
+
|
|
512
|
+
p = sub.add_parser("chat", help="interactive session (the default)")
|
|
513
|
+
p.add_argument("--session", help="resume this session id")
|
|
514
|
+
p.add_argument("-c", "--resume", action="store_true", help="resume the most recent session")
|
|
515
|
+
_add_agent_options(p)
|
|
516
|
+
p.set_defaults(func=cli_chat)
|
|
517
|
+
|
|
518
|
+
p = sub.add_parser("serve", help="run the HTTP API (needs flowai-cli[server])")
|
|
519
|
+
p.add_argument("--host", help="bind address (default 127.0.0.1)")
|
|
520
|
+
p.add_argument("--port", type=int, help="port (default 8000)")
|
|
521
|
+
p.set_defaults(func=cli_serve)
|
|
522
|
+
|
|
523
|
+
p = sub.add_parser("inspect", help="show config status, sessions and checkpoints")
|
|
524
|
+
p.set_defaults(func=cli_inspect)
|
|
525
|
+
|
|
526
|
+
p = sub.add_parser("tools", help="list built-in tools")
|
|
527
|
+
p.set_defaults(func=cli_tools)
|
|
528
|
+
return parser
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def normalise_argv(argv: list[str]) -> list[str]:
|
|
532
|
+
"""`flowai` alone means chat; `flowai "do X"` means run."""
|
|
533
|
+
if not argv:
|
|
534
|
+
return ["chat"]
|
|
535
|
+
first = argv[0]
|
|
536
|
+
if first in SUBCOMMANDS or first in ("-h", "--help", "--version"):
|
|
537
|
+
return argv
|
|
538
|
+
if first.startswith("-"):
|
|
539
|
+
# `flowai --yolo` and friends: options for an interactive session.
|
|
540
|
+
# One-shot runs with options use the explicit form: flowai run --model x "task".
|
|
541
|
+
return ["chat", *argv]
|
|
542
|
+
return ["run", *argv]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def main(argv: list[str] | None = None) -> int:
|
|
546
|
+
# Model output can hold any character; a legacy Windows code page must not crash on it.
|
|
547
|
+
for stream in (sys.stdout, sys.stderr):
|
|
548
|
+
if hasattr(stream, "reconfigure"):
|
|
549
|
+
stream.reconfigure(errors="replace")
|
|
550
|
+
args =build_parser().parse_args(normalise_argv(sys.argv[1:] if argv is None else argv))
|
|
551
|
+
try:
|
|
552
|
+
return args.func(args) or 0
|
|
553
|
+
except FlowAIError as exc:
|
|
554
|
+
ui.error(str(exc))
|
|
555
|
+
return 1
|
|
556
|
+
except KeyboardInterrupt:
|
|
557
|
+
print()
|
|
558
|
+
return 130
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
if __name__ == "__main__":
|
|
562
|
+
sys.exit(main())
|
flowai/cli/ui.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Terminal presentation helpers: colour, framing, and the approval prompt.
|
|
2
|
+
|
|
3
|
+
Output stays plain ASCII so it renders on every Windows console, and colour
|
|
4
|
+
switches itself off when the output is piped or NO_COLOR is set.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
RESET = "\033[0m"
|
|
13
|
+
BOLD = "\033[1m"
|
|
14
|
+
DIM = "\033[2m"
|
|
15
|
+
CYAN = "\033[36m"
|
|
16
|
+
GREEN = "\033[32m"
|
|
17
|
+
YELLOW = "\033[33m"
|
|
18
|
+
RED = "\033[31m"
|
|
19
|
+
|
|
20
|
+
MAX_ARG_PREVIEW = 400
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def colour_enabled() -> bool:
|
|
24
|
+
"""Colour only when a human is watching a terminal that wants it."""
|
|
25
|
+
if os.getenv("NO_COLOR") or os.getenv("FLOWAI_NO_COLOR"):
|
|
26
|
+
return False
|
|
27
|
+
return sys.stdout.isatty()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def paint(text: str, *codes: str) -> str:
|
|
31
|
+
if not codes or not colour_enabled():
|
|
32
|
+
return text
|
|
33
|
+
return "".join(codes) + text + RESET
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rule(width: int = 60) -> str:
|
|
37
|
+
return "-" * width
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def banner(lines: list[str]) -> str:
|
|
41
|
+
return "\n".join([rule(), *lines, rule()])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def info(message: str) -> None:
|
|
45
|
+
print(paint(message, DIM))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def warn(message: str) -> None:
|
|
49
|
+
print(paint(message, YELLOW))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def error(message: str) -> None:
|
|
53
|
+
print(paint(f"Error: {message}", RED), file=sys.stderr)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def format_arguments(arguments: dict) -> str:
|
|
57
|
+
"""One-line preview of tool arguments, shortened to stay readable."""
|
|
58
|
+
parts = []
|
|
59
|
+
for key, value in arguments.items():
|
|
60
|
+
text = value if isinstance(value, str) else repr(value)
|
|
61
|
+
text = text.replace("\n", "\\n")
|
|
62
|
+
if len(text) > 120:
|
|
63
|
+
text = text[:120] + "..."
|
|
64
|
+
parts.append(f"{key}={text}")
|
|
65
|
+
line = ", ".join(parts)
|
|
66
|
+
return line[:MAX_ARG_PREVIEW] + ("..." if len(line) > MAX_ARG_PREVIEW else "")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tool_start(name: str, arguments: dict) -> None:
|
|
70
|
+
print(paint(f"\n [tool] {name}({format_arguments(arguments)})", CYAN))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tool_end(name: str, result: str) -> None:
|
|
74
|
+
lines = (result or "").strip().splitlines()
|
|
75
|
+
summary = lines[0][:160] if lines else "(no output)"
|
|
76
|
+
extra = f" (+{len(lines) - 1} more lines)" if len(lines) > 1 else ""
|
|
77
|
+
print(paint(f" [done] {summary}{extra}", DIM))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ask_approval(name: str, arguments: dict) -> str:
|
|
81
|
+
"""Show what a tool is about to do and return 'yes', 'no' or 'always'."""
|
|
82
|
+
print(paint(f"\n {name} wants to run:", YELLOW))
|
|
83
|
+
for key, value in arguments.items():
|
|
84
|
+
text = value if isinstance(value, str) else repr(value)
|
|
85
|
+
lines = text.splitlines() or [""]
|
|
86
|
+
print(f" {key}: {lines[0]}")
|
|
87
|
+
for line in lines[1:20]:
|
|
88
|
+
print(f" {line}")
|
|
89
|
+
if len(lines) > 20:
|
|
90
|
+
print(f" ... ({len(lines) - 20} more lines)")
|
|
91
|
+
try:
|
|
92
|
+
answer = input(paint(" Allow? [y]es / [n]o / [a]lways: ", BOLD)).strip().lower()
|
|
93
|
+
except (EOFError, KeyboardInterrupt):
|
|
94
|
+
print()
|
|
95
|
+
return "no"
|
|
96
|
+
if answer in ("a", "always"):
|
|
97
|
+
return "always"
|
|
98
|
+
return "yes" if answer in ("y", "yes") else "no"
|