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.
Files changed (56) hide show
  1. flowai/__init__.py +72 -0
  2. flowai/__main__.py +8 -0
  3. flowai/agents/__init__.py +21 -0
  4. flowai/agents/base.py +61 -0
  5. flowai/agents/patterns/__init__.py +11 -0
  6. flowai/agents/patterns/debate.py +79 -0
  7. flowai/agents/patterns/reflection.py +74 -0
  8. flowai/agents/specialized.py +87 -0
  9. flowai/agents/standard.py +176 -0
  10. flowai/cli/__init__.py +5 -0
  11. flowai/cli/main.py +562 -0
  12. flowai/cli/ui.py +98 -0
  13. flowai/config/__init__.py +5 -0
  14. flowai/config/settings.py +204 -0
  15. flowai/errors.py +30 -0
  16. flowai/mcp/__init__.py +5 -0
  17. flowai/mcp/client.py +77 -0
  18. flowai/memory/__init__.py +16 -0
  19. flowai/memory/base.py +45 -0
  20. flowai/memory/chroma.py +97 -0
  21. flowai/memory/in_memory.py +65 -0
  22. flowai/memory/qdrant.py +104 -0
  23. flowai/observability/__init__.py +15 -0
  24. flowai/observability/events.py +45 -0
  25. flowai/observability/opentelemetry.py +56 -0
  26. flowai/observability/tracer.py +80 -0
  27. flowai/plugins/__init__.py +5 -0
  28. flowai/plugins/loader.py +31 -0
  29. flowai/providers/__init__.py +35 -0
  30. flowai/providers/anthropic.py +268 -0
  31. flowai/providers/base.py +180 -0
  32. flowai/providers/gemini.py +230 -0
  33. flowai/providers/nvidia.py +57 -0
  34. flowai/providers/ollama.py +237 -0
  35. flowai/providers/openai.py +274 -0
  36. flowai/providers/registry.py +50 -0
  37. flowai/runtime/__init__.py +5 -0
  38. flowai/runtime/session.py +76 -0
  39. flowai/server/__init__.py +5 -0
  40. flowai/server/app.py +133 -0
  41. flowai/tools/__init__.py +39 -0
  42. flowai/tools/base.py +56 -0
  43. flowai/tools/builtin.py +128 -0
  44. flowai/tools/decorator.py +103 -0
  45. flowai/tools/files.py +197 -0
  46. flowai/tools/registry.py +84 -0
  47. flowai/tools/web.py +94 -0
  48. flowai/workflow/__init__.py +15 -0
  49. flowai/workflow/checkpoint.py +73 -0
  50. flowai/workflow/engine.py +144 -0
  51. flowai/workflow/state.py +43 -0
  52. flowai_cli-0.2.0.dist-info/METADATA +223 -0
  53. flowai_cli-0.2.0.dist-info/RECORD +56 -0
  54. flowai_cli-0.2.0.dist-info/WHEEL +4 -0
  55. flowai_cli-0.2.0.dist-info/entry_points.txt +2 -0
  56. 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"
@@ -0,0 +1,5 @@
1
+ """FlowAI Config Package."""
2
+
3
+ from flowai.config.settings import MemoryConfig, ProviderConfig, ServerConfig, Settings
4
+
5
+ __all__ = ["Settings", "ProviderConfig", "MemoryConfig", "ServerConfig"]