orchid-cli 0.1.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.
orchid_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Orchid CLI
@@ -0,0 +1,186 @@
1
+ """
2
+ Shared bootstrapping — load config, build graph, initialize persistence.
3
+
4
+ Mirrors the lifespan logic from orchid-api but without FastAPI.
5
+ SQLite storage is always initialized by default (no external DB required).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from contextlib import asynccontextmanager
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from orchid_ai.config.loader import load_config
17
+ from orchid_ai.config.schema import AgentsConfig
18
+ from orchid_ai.core.repository import VectorReader, VectorStoreAdmin
19
+ from orchid_ai.graph.graph import build_graph
20
+ from orchid_ai.persistence.base import ChatStorage
21
+ from orchid_ai.persistence.factory import build_chat_storage
22
+ from orchid_ai.rag.factory import build_reader
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ── YAML key → env var mapping (same as orchid-api/settings.py) ──────
27
+ _YAML_TO_ENV: dict[tuple[str, str], str] = {
28
+ ("agents", "config_path"): "AGENTS_CONFIG_PATH",
29
+ ("llm", "model"): "LITELLM_MODEL",
30
+ ("llm", "ollama_api_base"): "OLLAMA_API_BASE",
31
+ ("llm", "groq_api_key"): "GROQ_API_KEY",
32
+ ("llm", "gemini_api_key"): "GEMINI_API_KEY",
33
+ ("llm", "anthropic_api_key"): "ANTHROPIC_API_KEY",
34
+ ("llm", "openai_api_key"): "OPENAI_API_KEY",
35
+ ("auth", "dev_bypass"): "DEV_AUTH_BYPASS",
36
+ ("auth", "identity_resolver_class"): "IDENTITY_RESOLVER_CLASS",
37
+ ("auth", "domain"): "AUTH_DOMAIN",
38
+ ("startup", "hook"): "STARTUP_HOOK",
39
+ ("rag", "vector_backend"): "VECTOR_BACKEND",
40
+ ("rag", "qdrant_url"): "QDRANT_URL",
41
+ ("rag", "embedding_model"): "EMBEDDING_MODEL",
42
+ ("rag", "openai_api_key"): "OPENAI_API_KEY",
43
+ ("rag", "gemini_api_key"): "GEMINI_API_KEY",
44
+ ("upload", "vision_model"): "VISION_MODEL",
45
+ ("upload", "namespace"): "UPLOAD_NAMESPACE",
46
+ ("upload", "max_size_mb"): "UPLOAD_MAX_SIZE_MB",
47
+ ("upload", "chunk_size"): "CHUNK_SIZE",
48
+ ("upload", "chunk_overlap"): "CHUNK_OVERLAP",
49
+ ("storage", "class"): "CHAT_STORAGE_CLASS",
50
+ ("storage", "dsn"): "CHAT_DB_DSN",
51
+ ("mcp", "catalog_url"): "MCP_CATALOG_URL",
52
+ ("mcp", "notifications_url"): "MCP_NOTIFICATIONS_URL",
53
+ ("tracing", "langsmith_tracing"): "LANGSMITH_TRACING",
54
+ ("tracing", "langsmith_api_key"): "LANGSMITH_API_KEY",
55
+ ("tracing", "langsmith_project"): "LANGSMITH_PROJECT",
56
+ }
57
+
58
+
59
+ def _apply_yaml_to_env(config_path: str) -> None:
60
+ """Parse orchid.yml and export values as env vars (if not already set).
61
+
62
+ Storage settings (class, dsn) are skipped — the CLI has its own
63
+ defaults (SQLite at ~/.orchid/chats.db) that are more appropriate
64
+ than Docker-oriented paths often found in orchid.yml.
65
+ """
66
+ try:
67
+ import yaml
68
+
69
+ with open(config_path) as f:
70
+ data = yaml.safe_load(f) or {}
71
+ except FileNotFoundError:
72
+ logger.warning("[CLI] Config file %s not found — ignoring", config_path)
73
+ return
74
+
75
+ # Storage config in orchid.yml is typically Docker-oriented (e.g. /data/chats.db).
76
+ # The CLI defaults to ~/.orchid/chats.db which is always writable.
77
+ _SKIP_SECTIONS = {"storage"}
78
+
79
+ for section, body in data.items():
80
+ if not isinstance(body, dict) or section in _SKIP_SECTIONS:
81
+ continue
82
+ for key, value in body.items():
83
+ env_var = _YAML_TO_ENV.get((section, key))
84
+ if env_var and env_var not in os.environ:
85
+ os.environ[env_var] = str(value)
86
+
87
+ logger.debug("[CLI] Applied YAML config from %s", config_path)
88
+
89
+
90
+ # Defaults — SQLite storage ships with orchid, no external DB needed
91
+ DEFAULT_STORAGE_CLASS = "orchid_ai.persistence.sqlite.SQLiteChatStorage"
92
+ DEFAULT_STORAGE_DSN = "~/.orchid/chats.db"
93
+
94
+
95
+ @dataclass
96
+ class OrchidContext:
97
+ """Runtime context for CLI operations."""
98
+
99
+ graph: Any
100
+ reader: VectorReader
101
+ chat_repo: ChatStorage
102
+ config: AgentsConfig
103
+ model: str
104
+
105
+
106
+ async def bootstrap(
107
+ config_path: str,
108
+ *,
109
+ model: str = "",
110
+ vector_backend: str = "",
111
+ qdrant_url: str = "",
112
+ embedding_model: str = "",
113
+ chat_storage_class: str = "",
114
+ chat_db_dsn: str = "",
115
+ ) -> OrchidContext:
116
+ """
117
+ Load config, build reader, build graph — return a ready-to-use context.
118
+
119
+ Chat storage is always initialized (defaults to SQLite at ~/.orchid/chats.db).
120
+ """
121
+ # Apply YAML config to environment — mirrors orchid-api/settings.py:_apply_yaml_config()
122
+ if config_path:
123
+ os.environ.setdefault("ORCHID_CONFIG", config_path)
124
+ _apply_yaml_to_env(config_path)
125
+
126
+ # Load YAML agent config
127
+ agents_config_path = os.environ.get("AGENTS_CONFIG_PATH", "agents.yaml")
128
+ agents_config = load_config(agents_config_path)
129
+
130
+ # Resolve defaults from env
131
+ resolved_model = model or os.environ.get("LITELLM_MODEL", "ollama/llama3.2")
132
+ resolved_backend = vector_backend or os.environ.get("VECTOR_BACKEND", "qdrant")
133
+ resolved_qdrant_url = qdrant_url or os.environ.get("QDRANT_URL", "http://qdrant:6333")
134
+ resolved_embedding = embedding_model or os.environ.get("EMBEDDING_MODEL", "text-embedding-3-small")
135
+
136
+ # Build reader
137
+ reader = build_reader(
138
+ vector_backend=resolved_backend,
139
+ qdrant_url=resolved_qdrant_url,
140
+ embedding_model=resolved_embedding,
141
+ )
142
+
143
+ # Ensure collections
144
+ if isinstance(reader, VectorStoreAdmin):
145
+ namespaces = [a.rag.namespace for a in agents_config.agents.values() if a.rag.enabled and a.rag.namespace]
146
+ if namespaces:
147
+ await reader.ensure_collections([*namespaces, "uploads"])
148
+
149
+ # Chat persistence — always initialized, defaults to SQLite
150
+ resolved_storage_class = chat_storage_class or os.environ.get("CHAT_STORAGE_CLASS", "") or DEFAULT_STORAGE_CLASS
151
+ resolved_dsn = chat_db_dsn or os.environ.get("CHAT_DB_DSN", "") or DEFAULT_STORAGE_DSN
152
+ chat_repo = build_chat_storage(class_path=resolved_storage_class, dsn=resolved_dsn)
153
+ await chat_repo.init_db()
154
+
155
+ # Build graph
156
+ graph = build_graph(
157
+ config=agents_config,
158
+ default_model=resolved_model,
159
+ reader=reader,
160
+ )
161
+
162
+ logger.info(
163
+ "[CLI] Ready — model=%s, backend=%s, storage=%s, agents=%s",
164
+ resolved_model,
165
+ resolved_backend,
166
+ resolved_storage_class.rsplit(".", 1)[-1],
167
+ list(agents_config.agents.keys()),
168
+ )
169
+
170
+ return OrchidContext(
171
+ graph=graph,
172
+ reader=reader,
173
+ chat_repo=chat_repo,
174
+ config=agents_config,
175
+ model=resolved_model,
176
+ )
177
+
178
+
179
+ @asynccontextmanager
180
+ async def cli_context(config_path: str, *, model: str = ""):
181
+ """Bootstrap and ensure clean shutdown (closes aiosqlite before event loop exits)."""
182
+ ctx = await bootstrap(config_path, model=model)
183
+ try:
184
+ yield ctx
185
+ finally:
186
+ await ctx.chat_repo.close()
@@ -0,0 +1 @@
1
+ # CLI commands package
@@ -0,0 +1,428 @@
1
+ """
2
+ Chat commands — full CRUD + message send with persistence.
3
+
4
+ Mirrors orchid-api chat endpoints:
5
+ orchid chat create [--title "My Chat"]
6
+ orchid chat list
7
+ orchid chat delete <chat_id>
8
+ orchid chat history <chat_id>
9
+ orchid chat send <chat_id> "message"
10
+ orchid chat interactive [--chat <chat_id>]
11
+ orchid chat rename <chat_id> "new title"
12
+ orchid chat share <chat_id>
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from typing import Optional
19
+
20
+ import typer
21
+ from langchain_core.messages import AIMessage, HumanMessage
22
+ from rich.console import Console
23
+ from rich.table import Table
24
+
25
+ from orchid_ai.core.state import AuthContext
26
+
27
+ from ..bootstrap import cli_context
28
+
29
+ app = typer.Typer(help="Chat management and messaging", no_args_is_help=True)
30
+ console = Console()
31
+
32
+ # CLI uses a fixed auth context — no OAuth needed
33
+ _CLI_AUTH = AuthContext(
34
+ access_token="cli-token",
35
+ tenant_key="cli",
36
+ user_id="cli-user",
37
+ )
38
+
39
+
40
+ # ── Chat CRUD ───────────────────────────────────────────────
41
+
42
+
43
+ @app.command()
44
+ def create(
45
+ title: str = typer.Option("New chat", "--title", "-t", help="Chat title"),
46
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
47
+ model: str = typer.Option("", "--model", "-m", help="Override LLM model"),
48
+ ):
49
+ """Create a new chat session."""
50
+ asyncio.run(_create(title, config, model))
51
+
52
+
53
+ async def _create(title: str, config_path: str, model: str) -> None:
54
+ async with cli_context(config_path, model=model) as ctx:
55
+ session = await ctx.chat_repo.create_chat(
56
+ tenant_id=_CLI_AUTH.tenant_key,
57
+ user_id=_CLI_AUTH.user_id,
58
+ title=title,
59
+ )
60
+ console.print(f"[bold green]Created:[/bold green] {session.id}")
61
+ console.print(f" Title: {session.title}")
62
+ console.print(f" Created: {session.created_at.isoformat()}")
63
+
64
+
65
+ @app.command("list")
66
+ def list_chats(
67
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
68
+ model: str = typer.Option("", "--model", "-m", help="Override LLM model"),
69
+ ):
70
+ """List all chat sessions."""
71
+ asyncio.run(_list_chats(config, model))
72
+
73
+
74
+ async def _list_chats(config_path: str, model: str) -> None:
75
+ async with cli_context(config_path, model=model) as ctx:
76
+ sessions = await ctx.chat_repo.list_chats(
77
+ tenant_id=_CLI_AUTH.tenant_key,
78
+ user_id=_CLI_AUTH.user_id,
79
+ )
80
+
81
+ if not sessions:
82
+ console.print("[dim]No chats found. Use 'orchid chat create' to start one.[/dim]")
83
+ return
84
+
85
+ table = Table(title="Chat Sessions")
86
+ table.add_column("ID", style="cyan", no_wrap=True)
87
+ table.add_column("Title", style="white")
88
+ table.add_column("Messages", justify="right")
89
+ table.add_column("Updated", style="dim")
90
+ table.add_column("Shared", justify="center")
91
+
92
+ for s in sessions:
93
+ messages = await ctx.chat_repo.get_messages(s.id, limit=1000)
94
+ table.add_row(
95
+ s.id[:12] + "…",
96
+ s.title[:40],
97
+ str(len(messages)),
98
+ s.updated_at.strftime("%Y-%m-%d %H:%M"),
99
+ "✓" if s.is_shared else "",
100
+ )
101
+
102
+ console.print(table)
103
+
104
+
105
+ @app.command()
106
+ def delete(
107
+ chat_id: str = typer.Argument(..., help="Chat ID (or prefix)"),
108
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
109
+ model: str = typer.Option("", "--model", "-m", help="Override LLM model"),
110
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
111
+ ):
112
+ """Delete a chat session and all its messages."""
113
+ asyncio.run(_delete(chat_id, config, model, force))
114
+
115
+
116
+ async def _delete(chat_id: str, config_path: str, model: str, force: bool) -> None:
117
+ async with cli_context(config_path, model=model) as ctx:
118
+ resolved_id = await _resolve_chat_id(ctx, chat_id)
119
+ if not resolved_id:
120
+ return
121
+
122
+ chat = await ctx.chat_repo.get_chat(resolved_id)
123
+ if not force:
124
+ confirm = typer.confirm(f"Delete chat '{chat.title}' ({resolved_id[:12]}…)?")
125
+ if not confirm:
126
+ console.print("[dim]Cancelled.[/dim]")
127
+ return
128
+
129
+ await ctx.chat_repo.delete_chat(resolved_id)
130
+ console.print(f"[bold red]Deleted:[/bold red] {resolved_id[:12]}…")
131
+
132
+
133
+ @app.command()
134
+ def history(
135
+ chat_id: str = typer.Argument(..., help="Chat ID (or prefix)"),
136
+ limit: int = typer.Option(50, "--limit", "-n", help="Max messages to show"),
137
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
138
+ model: str = typer.Option("", "--model", "-m", help="Override LLM model"),
139
+ ):
140
+ """Show message history for a chat."""
141
+ asyncio.run(_history(chat_id, limit, config, model))
142
+
143
+
144
+ async def _history(chat_id: str, limit: int, config_path: str, model: str) -> None:
145
+ async with cli_context(config_path, model=model) as ctx:
146
+ resolved_id = await _resolve_chat_id(ctx, chat_id)
147
+ if not resolved_id:
148
+ return
149
+
150
+ chat = await ctx.chat_repo.get_chat(resolved_id)
151
+ messages = await ctx.chat_repo.get_messages(resolved_id, limit=limit)
152
+
153
+ console.print(f"[bold]{chat.title}[/bold] ({resolved_id[:12]}…)")
154
+ console.print()
155
+
156
+ if not messages:
157
+ console.print("[dim]No messages yet.[/dim]")
158
+ return
159
+
160
+ for msg in messages:
161
+ if msg.role == "user":
162
+ console.print(f"[bold cyan]You:[/bold cyan] {msg.content}")
163
+ elif msg.role == "assistant":
164
+ console.print(f"[bold green]Assistant:[/bold green] {msg.content}")
165
+ if msg.agents_used:
166
+ console.print(f" [dim]Agents: {', '.join(msg.agents_used)}[/dim]")
167
+ else:
168
+ console.print(f"[dim]{msg.role}: {msg.content}[/dim]")
169
+ console.print()
170
+
171
+
172
+ @app.command()
173
+ def rename(
174
+ chat_id: str = typer.Argument(..., help="Chat ID (or prefix)"),
175
+ title: str = typer.Argument(..., help="New title"),
176
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
177
+ model: str = typer.Option("", "--model", "-m", help="Override LLM model"),
178
+ ):
179
+ """Rename a chat session."""
180
+ asyncio.run(_rename(chat_id, title, config, model))
181
+
182
+
183
+ async def _rename(chat_id: str, title: str, config_path: str, model: str) -> None:
184
+ async with cli_context(config_path, model=model) as ctx:
185
+ resolved_id = await _resolve_chat_id(ctx, chat_id)
186
+ if not resolved_id:
187
+ return
188
+
189
+ await ctx.chat_repo.update_title(resolved_id, title)
190
+ console.print(f"[bold]Renamed:[/bold] {resolved_id[:12]}… → {title}")
191
+
192
+
193
+ @app.command()
194
+ def share(
195
+ chat_id: str = typer.Argument(..., help="Chat ID (or prefix)"),
196
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
197
+ model: str = typer.Option("", "--model", "-m", help="Override LLM model"),
198
+ ):
199
+ """Mark a chat as shared."""
200
+ asyncio.run(_share(chat_id, config, model))
201
+
202
+
203
+ async def _share(chat_id: str, config_path: str, model: str) -> None:
204
+ async with cli_context(config_path, model=model) as ctx:
205
+ resolved_id = await _resolve_chat_id(ctx, chat_id)
206
+ if not resolved_id:
207
+ return
208
+
209
+ await ctx.chat_repo.mark_shared(resolved_id)
210
+ console.print(f"[bold]Shared:[/bold] {resolved_id[:12]}…")
211
+
212
+
213
+ # ── Messaging ───────────────────────────────────────────────
214
+
215
+
216
+ @app.command()
217
+ def send(
218
+ chat_id: str = typer.Argument(..., help="Chat ID (or prefix)"),
219
+ message: str = typer.Argument(..., help="The message to send"),
220
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
221
+ model: str = typer.Option("", "--model", "-m", help="Override LLM model"),
222
+ ):
223
+ """Send a message to a chat and print the response."""
224
+ asyncio.run(_send(chat_id, message, config, model))
225
+
226
+
227
+ async def _send(chat_id: str, message: str, config_path: str, model: str) -> None:
228
+ async with cli_context(config_path, model=model) as ctx:
229
+ resolved_id = await _resolve_chat_id(ctx, chat_id)
230
+ if not resolved_id:
231
+ return
232
+
233
+ response_text, agents_used = await _send_message(ctx, resolved_id, message)
234
+
235
+ console.print()
236
+ console.print(response_text)
237
+ if agents_used:
238
+ console.print(f"\n[dim]Agents used: {', '.join(agents_used)}[/dim]")
239
+
240
+
241
+ @app.command()
242
+ def interactive(
243
+ chat_id: Optional[str] = typer.Argument(None, help="Chat ID to resume (or prefix). Creates new if omitted."),
244
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
245
+ model: str = typer.Option("", "--model", "-m", help="Override LLM model"),
246
+ ):
247
+ """Start an interactive chat REPL with full persistence."""
248
+ asyncio.run(_interactive(chat_id, config, model))
249
+
250
+
251
+ async def _interactive(chat_id: str | None, config_path: str, model: str) -> None:
252
+ async with cli_context(config_path, model=model) as ctx:
253
+ # Resolve or create a chat
254
+ if chat_id:
255
+ resolved_id = await _resolve_chat_id(ctx, chat_id)
256
+ if not resolved_id:
257
+ return
258
+ chat = await ctx.chat_repo.get_chat(resolved_id)
259
+ console.print(f"[bold]Resuming:[/bold] {chat.title} ({resolved_id[:12]}…)")
260
+ else:
261
+ chat = await ctx.chat_repo.create_chat(
262
+ tenant_id=_CLI_AUTH.tenant_key,
263
+ user_id=_CLI_AUTH.user_id,
264
+ title="Interactive session",
265
+ )
266
+ resolved_id = chat.id
267
+ console.print(f"[bold]New chat:[/bold] {resolved_id[:12]}…")
268
+
269
+ console.print()
270
+ console.print("[bold]Orchid Interactive Chat[/bold]")
271
+ console.print("Commands: /quit, /switch <id>, /list, /new [title], /history, /rename <title>")
272
+ console.print()
273
+
274
+ current_chat_id = resolved_id
275
+
276
+ while True:
277
+ try:
278
+ user_input = console.input("[bold cyan]You:[/bold cyan] ")
279
+ except (EOFError, KeyboardInterrupt):
280
+ break
281
+
282
+ stripped = user_input.strip()
283
+ if not stripped:
284
+ continue
285
+
286
+ # Handle slash commands
287
+ if stripped.startswith("/"):
288
+ parts = stripped.split(maxsplit=1)
289
+ cmd = parts[0].lower()
290
+ arg = parts[1] if len(parts) > 1 else ""
291
+
292
+ if cmd in ("/quit", "/exit", "/q"):
293
+ break
294
+
295
+ elif cmd == "/list":
296
+ sessions = await ctx.chat_repo.list_chats(
297
+ tenant_id=_CLI_AUTH.tenant_key,
298
+ user_id=_CLI_AUTH.user_id,
299
+ )
300
+ if not sessions:
301
+ console.print("[dim]No chats.[/dim]")
302
+ else:
303
+ for s in sessions:
304
+ marker = " [bold]← current[/bold]" if s.id == current_chat_id else ""
305
+ console.print(f" {s.id[:12]}… {s.title}{marker}")
306
+ console.print()
307
+ continue
308
+
309
+ elif cmd == "/switch":
310
+ if not arg:
311
+ console.print("[red]Usage: /switch <chat_id>[/red]")
312
+ continue
313
+ new_id = await _resolve_chat_id(ctx, arg)
314
+ if new_id:
315
+ current_chat_id = new_id
316
+ chat = await ctx.chat_repo.get_chat(current_chat_id)
317
+ console.print(f"[bold]Switched to:[/bold] {chat.title} ({current_chat_id[:12]}…)\n")
318
+ continue
319
+
320
+ elif cmd == "/new":
321
+ title = arg or "Interactive session"
322
+ new_chat = await ctx.chat_repo.create_chat(
323
+ tenant_id=_CLI_AUTH.tenant_key,
324
+ user_id=_CLI_AUTH.user_id,
325
+ title=title,
326
+ )
327
+ current_chat_id = new_chat.id
328
+ console.print(f"[bold green]New chat:[/bold green] {current_chat_id[:12]}… — {title}\n")
329
+ continue
330
+
331
+ elif cmd == "/history":
332
+ messages = await ctx.chat_repo.get_messages(current_chat_id, limit=20)
333
+ if not messages:
334
+ console.print("[dim]No messages yet.[/dim]\n")
335
+ else:
336
+ for msg in messages:
337
+ if msg.role == "user":
338
+ console.print(f" [cyan]You:[/cyan] {msg.content[:80]}")
339
+ elif msg.role == "assistant":
340
+ console.print(f" [green]Asst:[/green] {msg.content[:80]}")
341
+ console.print()
342
+ continue
343
+
344
+ elif cmd == "/rename":
345
+ if not arg:
346
+ console.print("[red]Usage: /rename <new title>[/red]")
347
+ continue
348
+ await ctx.chat_repo.update_title(current_chat_id, arg)
349
+ console.print(f"[bold]Renamed:[/bold] {arg}\n")
350
+ continue
351
+
352
+ else:
353
+ console.print(f"[red]Unknown command: {cmd}[/red]")
354
+ continue
355
+
356
+ # Send message
357
+ response_text, agents_used = await _send_message(ctx, current_chat_id, stripped)
358
+ console.print(f"\n[bold green]Assistant:[/bold green] {response_text}")
359
+ if agents_used:
360
+ console.print(f" [dim]Agents: {', '.join(agents_used)}[/dim]")
361
+ console.print()
362
+
363
+ console.print("\n[dim]Session ended.[/dim]")
364
+
365
+
366
+ # ── Helpers ─────────────────────────────────────────────────
367
+
368
+
369
+ async def _send_message(ctx, chat_id: str, message: str) -> tuple[str, list[str]]:
370
+ """Send a message through the graph, persist to storage, return (response, agents_used)."""
371
+ # Load history
372
+ history_rows = await ctx.chat_repo.get_messages(chat_id, limit=50)
373
+ history_messages = []
374
+ for row in history_rows:
375
+ if row.role == "user":
376
+ history_messages.append(HumanMessage(content=row.content, id=row.id))
377
+ elif row.role == "assistant":
378
+ history_messages.append(AIMessage(content=row.content, id=row.id))
379
+
380
+ initial_state = {
381
+ "messages": history_messages + [HumanMessage(content=message)],
382
+ "auth_context": _CLI_AUTH,
383
+ "chat_id": chat_id,
384
+ }
385
+
386
+ result = await ctx.graph.ainvoke(initial_state)
387
+
388
+ response_text = result.get("final_response", "No response generated.")
389
+ agents_used = result.get("active_agents", [])
390
+
391
+ # Persist original message + response
392
+ await ctx.chat_repo.add_message(chat_id, "user", message)
393
+ await ctx.chat_repo.add_message(chat_id, "assistant", response_text, agents_used=agents_used)
394
+
395
+ # Auto-title from first message
396
+ if not history_rows:
397
+ title = message[:50].strip()
398
+ if len(message) > 50:
399
+ title += "…"
400
+ await ctx.chat_repo.update_title(chat_id, title)
401
+
402
+ return response_text, agents_used
403
+
404
+
405
+ async def _resolve_chat_id(ctx, chat_id_prefix: str) -> str | None:
406
+ """Resolve a chat ID prefix to a full ID. Prints error if not found."""
407
+ # Try exact match first
408
+ chat = await ctx.chat_repo.get_chat(chat_id_prefix)
409
+ if chat and chat.user_id == _CLI_AUTH.user_id:
410
+ return chat.id
411
+
412
+ # Try prefix match
413
+ sessions = await ctx.chat_repo.list_chats(
414
+ tenant_id=_CLI_AUTH.tenant_key,
415
+ user_id=_CLI_AUTH.user_id,
416
+ )
417
+ matches = [s for s in sessions if s.id.startswith(chat_id_prefix)]
418
+
419
+ if len(matches) == 1:
420
+ return matches[0].id
421
+ elif len(matches) > 1:
422
+ console.print(f"[red]Ambiguous prefix '{chat_id_prefix}' — matches {len(matches)} chats:[/red]")
423
+ for s in matches:
424
+ console.print(f" {s.id[:12]}… {s.title}")
425
+ return None
426
+ else:
427
+ console.print(f"[red]Chat not found: {chat_id_prefix}[/red]")
428
+ return None
@@ -0,0 +1,38 @@
1
+ """
2
+ Config command — validate YAML configuration files.
3
+
4
+ Usage:
5
+ orchid config validate examples/basketball/orchid.yml
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from orchid_ai.config.loader import load_config
14
+
15
+ app = typer.Typer(help="Configuration management", no_args_is_help=True)
16
+ console = Console()
17
+
18
+
19
+ @app.command()
20
+ def validate(
21
+ config_path: str = typer.Argument(..., help="Path to agents.yaml config file"),
22
+ ):
23
+ """Validate an agents.yaml configuration file."""
24
+ try:
25
+ config = load_config(config_path)
26
+ console.print(f"[green]Valid[/green] — {len(config.agents)} agent(s) configured:")
27
+ for name, agent_cfg in config.agents.items():
28
+ desc = agent_cfg.description[:60] if agent_cfg.description else "(no description)"
29
+ cls = agent_cfg.class_path or "GenericAgent"
30
+ console.print(f" [bold]{name}[/bold] ({cls}) — {desc}")
31
+
32
+ if config.supervisor:
33
+ console.print(f"\n Supervisor: assistant_name={config.supervisor.assistant_name!r}")
34
+ if config.skills:
35
+ console.print(f" Orchestrator skills: {len(config.skills)}")
36
+ except Exception as exc:
37
+ console.print(f"[red]Invalid[/red] — {exc}")
38
+ raise typer.Exit(code=1)
@@ -0,0 +1,45 @@
1
+ """
2
+ Index command — seed the vector store with data.
3
+
4
+ Usage:
5
+ orchid index seed --config examples/basketball/orchid.yml --tenant default
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from orchid_ai.core.repository import VectorWriter
16
+ from orchid_ai.rag.indexer import StaticIndexer
17
+
18
+
19
+ app = typer.Typer(help="Vector store indexing", no_args_is_help=True)
20
+ console = Console()
21
+
22
+
23
+ @app.command()
24
+ def seed(
25
+ config: str = typer.Option("", "--config", "-c", help="Path to orchid.yml"),
26
+ tenant: str = typer.Option("default", "--tenant", "-t", help="Tenant ID for indexing"),
27
+ ):
28
+ """Seed the vector store with static data."""
29
+ asyncio.run(_seed(config, tenant))
30
+
31
+
32
+ async def _seed(config_path: str, tenant: str) -> None:
33
+ from ..bootstrap import cli_context
34
+
35
+ async with cli_context(config_path) as ctx:
36
+ if not isinstance(ctx.reader, VectorWriter):
37
+ console.print("[red]Error:[/red] Vector store does not support writing (backend may be 'null')")
38
+ raise typer.Exit(code=1)
39
+
40
+ indexer = StaticIndexer(writer=ctx.reader)
41
+ counts = await indexer.index_all(tenant_key=tenant)
42
+
43
+ console.print(f"[green]Indexed[/green] for tenant={tenant}:")
44
+ for namespace, count in counts.items():
45
+ console.print(f" {namespace}: {count} document(s)")
@@ -0,0 +1,539 @@
1
+ """
2
+ Skill command — generate Claude Code skills from Orchid agent configuration.
3
+
4
+ Usage:
5
+ orchid skill generate examples/basketball/agents.yaml --output .claude/skills
6
+ orchid skill generate examples/helpdesk/config/agents.yaml -o ./skills --include basketball,psychologist
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ import inspect
13
+ import shutil
14
+ from pathlib import Path
15
+
16
+ import typer
17
+ from rich.console import Console
18
+
19
+ from orchid_ai.config.loader import load_config
20
+ from orchid_ai.config.schema import (
21
+ AgentConfig,
22
+ AgentsConfig,
23
+ BuiltinToolConfig,
24
+ OrchestratorSkillConfig,
25
+ )
26
+
27
+ app = typer.Typer(help="Generate Claude Code skills from Orchid config", no_args_is_help=True)
28
+ console = Console()
29
+
30
+
31
+ # ── Public command ────────────────────────────────────────────────
32
+
33
+
34
+ @app.command()
35
+ def generate(
36
+ config_path: str = typer.Argument(..., help="Path to agents.yaml config file"),
37
+ output: str = typer.Option(".claude/skills", "-o", "--output", help="Output directory for generated skills"),
38
+ include: str | None = typer.Option(
39
+ None, "--include", help="Comma-separated agent/skill names to include (default: all)"
40
+ ),
41
+ overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite existing skill directories"),
42
+ zip_archive: bool = typer.Option(False, "--zip", help="Create a zip archive of the generated skills"),
43
+ ) -> None:
44
+ """Generate Claude Code skill folders from an Orchid agents.yaml configuration."""
45
+ try:
46
+ config = load_config(config_path)
47
+ except Exception as exc:
48
+ console.print(f"[red]Error loading config:[/red] {exc}")
49
+ raise typer.Exit(code=1)
50
+
51
+ out_dir = Path(output)
52
+ include_set = {n.strip() for n in include.split(",")} if include else None
53
+
54
+ generated: list[str] = []
55
+ skipped: list[str] = []
56
+
57
+ # Generate agent skills
58
+ for agent_name, agent_cfg in config.agents.items():
59
+ if include_set and agent_name not in include_set:
60
+ continue
61
+ skill_dir = out_dir / agent_name
62
+ if skill_dir.exists() and not overwrite:
63
+ skipped.append(agent_name)
64
+ continue
65
+ _generate_agent_skill(skill_dir, agent_name, agent_cfg, config)
66
+ generated.append(agent_name)
67
+
68
+ # Generate orchestrator skills
69
+ for skill_name, skill_cfg in config.skills.items():
70
+ if include_set and skill_name not in include_set:
71
+ continue
72
+ skill_dir = out_dir / skill_name
73
+ if skill_dir.exists() and not overwrite:
74
+ skipped.append(skill_name)
75
+ continue
76
+ _generate_orchestrator_skill(skill_dir, skill_name, skill_cfg, config)
77
+ generated.append(skill_name)
78
+
79
+ # Summary
80
+ if generated:
81
+ console.print(f"\n[green]Generated {len(generated)} skill(s):[/green]")
82
+ for name in generated:
83
+ console.print(f" [bold]{out_dir / name}/SKILL.md[/bold]")
84
+ if skipped:
85
+ console.print(f"\n[yellow]Skipped {len(skipped)} (already exist, use --overwrite):[/yellow]")
86
+ for name in skipped:
87
+ console.print(f" {name}")
88
+ if not generated and not skipped:
89
+ console.print("[yellow]No agents or skills matched the filter.[/yellow]")
90
+
91
+ # Create zip archive if requested
92
+ if zip_archive and generated:
93
+ zip_path = Path(f"{output.rstrip('/')}.zip")
94
+ shutil.make_archive(str(zip_path.with_suffix("")), "zip", root_dir=out_dir.parent, base_dir=out_dir.name)
95
+ console.print(f"\n[green]Archive created:[/green] [bold]{zip_path}[/bold]")
96
+
97
+
98
+ # ── Agent skill generation ────────────────────────────────────────
99
+
100
+
101
+ def _generate_agent_skill(
102
+ skill_dir: Path,
103
+ agent_name: str,
104
+ agent_cfg: AgentConfig,
105
+ config: AgentsConfig,
106
+ ) -> None:
107
+ """Generate a Claude Code skill directory for one Orchid agent."""
108
+ skill_dir.mkdir(parents=True, exist_ok=True)
109
+
110
+ # Generate executable scripts for built-in tools
111
+ tool_scripts = _generate_tool_scripts(skill_dir, agent_cfg, config)
112
+
113
+ # Build SKILL.md
114
+ skill_md = _build_agent_skill_md(agent_name, agent_cfg, config, tool_scripts)
115
+ (skill_dir / "SKILL.md").write_text(skill_md, encoding="utf-8")
116
+
117
+
118
+ def _build_agent_skill_md(
119
+ agent_name: str,
120
+ agent_cfg: AgentConfig,
121
+ config: AgentsConfig,
122
+ tool_scripts: dict[str, _ToolScriptInfo],
123
+ ) -> str:
124
+ """Build the SKILL.md content for an Orchid agent."""
125
+ parts: list[str] = []
126
+
127
+ # ── Frontmatter ──
128
+ description = _clean_description(agent_cfg.description)
129
+ allowed: list[str] = []
130
+ if tool_scripts:
131
+ allowed.append("Bash(python *)")
132
+ frontmatter_lines = [
133
+ "---",
134
+ f"name: {agent_name}",
135
+ f'description: "{_truncate(description, 240)}"',
136
+ ]
137
+ if allowed:
138
+ frontmatter_lines.append(f'allowed-tools: "{" ".join(allowed)}"')
139
+ frontmatter_lines.append("---")
140
+ parts.append("\n".join(frontmatter_lines) + "\n")
141
+
142
+ # ── Title + origin ──
143
+ parts.append(f"# {agent_name}\n")
144
+ parts.append(
145
+ f"> Auto-generated from Orchid agent configuration. "
146
+ f"This skill replicates the knowledge and instructions of the `{agent_name}` agent.\n"
147
+ )
148
+
149
+ # ── Agent system prompt (the core value) ──
150
+ parts.append("## Instructions\n")
151
+ parts.append(agent_cfg.prompt.strip() + "\n")
152
+
153
+ # ── Built-in tools as executable scripts ──
154
+ if tool_scripts:
155
+ parts.append("## Available Tools\n")
156
+ parts.append(
157
+ "This skill includes executable Python scripts for each built-in tool. Run them to get real results.\n"
158
+ )
159
+ for tool_name, info in tool_scripts.items():
160
+ tool_cfg = config.tools.get(tool_name)
161
+ desc = tool_cfg.description if tool_cfg else ""
162
+ parts.append(f"### {tool_name}\n")
163
+ if desc:
164
+ parts.append(f"{desc}\n")
165
+ # Show usage with the actual parameters
166
+ parts.append(f"```bash\npython ${{CLAUDE_SKILL_DIR}}/scripts/{info.script_name} {info.usage_hint}\n```\n")
167
+ if info.parameters:
168
+ parts.append("**Parameters:**\n")
169
+ for param_name, param_desc in info.parameters.items():
170
+ parts.append(f"- `{param_name}`: {param_desc}")
171
+ parts.append("")
172
+
173
+ # ── MCP servers (informational, not portable) ──
174
+ if agent_cfg.mcp_servers:
175
+ parts.append("## External Integrations (Orchid Runtime Required)\n")
176
+ parts.append(
177
+ "The following MCP server integrations are available in the Orchid runtime "
178
+ "but cannot be used directly in Claude Code skills.\n"
179
+ )
180
+ for srv in agent_cfg.mcp_servers:
181
+ tool_list = ", ".join(t.name for t in srv.tools) if srv.tools else "(all)"
182
+ parts.append(f"- **{srv.name}** ({srv.transport}): tools = {tool_list}")
183
+ parts.append("")
184
+
185
+ # ── Agent-level skills (workflows) ──
186
+ if agent_cfg.skills:
187
+ parts.append("## Workflows\n")
188
+ parts.append(
189
+ "The original agent supports these multi-step workflows. "
190
+ "Follow these step sequences when the user's request matches.\n"
191
+ )
192
+ for skill_name, skill_cfg in agent_cfg.skills.items():
193
+ parts.append(f"### {skill_name}\n")
194
+ if skill_cfg.description:
195
+ parts.append(f"{skill_cfg.description.strip()}\n")
196
+ parts.append("**Steps:**\n")
197
+ for i, step in enumerate(skill_cfg.steps, 1):
198
+ if step.tool:
199
+ info = tool_scripts.get(step.tool)
200
+ if info:
201
+ parts.append(
202
+ f"{i}. Run `python ${{CLAUDE_SKILL_DIR}}/scripts/{info.script_name} {info.usage_hint}`"
203
+ )
204
+ else:
205
+ src = f" (from {step.source})" if step.source else ""
206
+ parts.append(f"{i}. Call tool `{step.tool}`{src}")
207
+ elif step.agent:
208
+ parts.append(f"{i}. Delegate to agent `{step.agent}`: {step.instruction}")
209
+ parts.append("")
210
+
211
+ # ── RAG context note ──
212
+ if agent_cfg.rag.enabled:
213
+ ns = agent_cfg.rag.namespace or agent_name
214
+ parts.append("## RAG Context (Orchid Runtime Required)\n")
215
+ parts.append(
216
+ f"In the Orchid runtime, this agent retrieves contextual documents from "
217
+ f"the `{ns}` namespace (top-{agent_cfg.rag.k} results). "
218
+ f"This capability is not available in the Claude Code skill.\n"
219
+ )
220
+
221
+ return "\n".join(parts)
222
+
223
+
224
+ # ── Orchestrator skill generation ─────────────────────────────────
225
+
226
+
227
+ def _generate_orchestrator_skill(
228
+ skill_dir: Path,
229
+ skill_name: str,
230
+ skill_cfg: OrchestratorSkillConfig,
231
+ config: AgentsConfig,
232
+ ) -> None:
233
+ """Generate a Claude Code skill directory for an Orchid orchestrator skill."""
234
+ skill_dir.mkdir(parents=True, exist_ok=True)
235
+
236
+ skill_md = _build_orchestrator_skill_md(skill_name, skill_cfg, config)
237
+ (skill_dir / "SKILL.md").write_text(skill_md, encoding="utf-8")
238
+
239
+
240
+ def _build_orchestrator_skill_md(
241
+ skill_name: str,
242
+ skill_cfg: OrchestratorSkillConfig,
243
+ config: AgentsConfig,
244
+ ) -> str:
245
+ """Build the SKILL.md content for an Orchid orchestrator skill."""
246
+ parts: list[str] = []
247
+
248
+ description = _clean_description(skill_cfg.description)
249
+ parts.append(f'---\nname: {skill_name.replace("_", "-")}\ndescription: "{_truncate(description, 240)}"\n---\n')
250
+
251
+ parts.append(f"# {skill_name.replace('_', ' ').title()}\n")
252
+ parts.append(
253
+ "> Auto-generated from Orchid orchestrator skill. "
254
+ "This is a multi-agent workflow that coordinates several specialists.\n"
255
+ )
256
+
257
+ if skill_cfg.description:
258
+ parts.append("## Purpose\n")
259
+ parts.append(skill_cfg.description.strip() + "\n")
260
+
261
+ # ── Workflow steps ──
262
+ parts.append("## Workflow Steps\n")
263
+ parts.append("Execute these steps in order. Each step's output feeds into the next.\n")
264
+ for i, step in enumerate(skill_cfg.steps, 1):
265
+ agent_cfg = config.agents.get(step.agent)
266
+ agent_desc = _clean_description(agent_cfg.description) if agent_cfg else ""
267
+ parts.append(f"### Step {i}: {step.agent}\n")
268
+ if agent_desc:
269
+ parts.append(f"*Agent role: {agent_desc}*\n")
270
+ if step.instruction:
271
+ parts.append(f"**Instruction:** {step.instruction}\n")
272
+ # Include the agent's prompt as context
273
+ if agent_cfg:
274
+ parts.append("<details>\n<summary>Agent system prompt</summary>\n")
275
+ parts.append(f"```\n{agent_cfg.prompt.strip()}\n```\n")
276
+ parts.append("</details>\n")
277
+
278
+ # ── Participating agents summary ──
279
+ agent_names = [s.agent for s in skill_cfg.steps]
280
+ parts.append("## Participating Agents\n")
281
+ for name in dict.fromkeys(agent_names): # unique, preserving order
282
+ agent_cfg = config.agents.get(name)
283
+ if agent_cfg:
284
+ desc = _clean_description(agent_cfg.description)
285
+ parts.append(f"- **{name}**: {desc}")
286
+ parts.append("")
287
+
288
+ return "\n".join(parts)
289
+
290
+
291
+ # ── Tool script generation ────────────────────────────────────────
292
+
293
+
294
+ class _ToolScriptInfo:
295
+ """Metadata about a generated tool script."""
296
+
297
+ __slots__ = ("script_name", "usage_hint", "parameters")
298
+
299
+ def __init__(self, script_name: str, usage_hint: str, parameters: dict[str, str]) -> None:
300
+ self.script_name = script_name
301
+ self.usage_hint = usage_hint
302
+ self.parameters = parameters
303
+
304
+
305
+ def _generate_tool_scripts(
306
+ skill_dir: Path,
307
+ agent_cfg: AgentConfig,
308
+ config: AgentsConfig,
309
+ ) -> dict[str, _ToolScriptInfo]:
310
+ """Generate executable Python scripts for each built-in tool.
311
+
312
+ Groups tools from the same source module into a single script file.
313
+ Returns a mapping of tool_name -> _ToolScriptInfo.
314
+ """
315
+ tool_names = agent_cfg.tools
316
+ if not tool_names:
317
+ return {}
318
+
319
+ scripts_dir = skill_dir / "scripts"
320
+ scripts_dir.mkdir(parents=True, exist_ok=True)
321
+
322
+ # Group tools by source module path
323
+ module_tools: dict[str, list[tuple[str, BuiltinToolConfig]]] = {}
324
+ for tool_name in tool_names:
325
+ tool_cfg = config.tools.get(tool_name)
326
+ if not tool_cfg:
327
+ continue
328
+ module_path = tool_cfg.handler.rsplit(".", 1)[0]
329
+ module_tools.setdefault(module_path, []).append((tool_name, tool_cfg))
330
+
331
+ result: dict[str, _ToolScriptInfo] = {}
332
+
333
+ for module_path, tools_in_module in module_tools.items():
334
+ # Read the source file of the module
335
+ source = _read_module_source(module_path)
336
+ if source is None:
337
+ continue
338
+
339
+ # Determine script filename from the last part of the module path
340
+ module_short_name = module_path.rsplit(".", 1)[-1]
341
+ script_name = f"{module_short_name}.py"
342
+
343
+ # Build the __main__ CLI wrapper
344
+ cli_wrapper = _build_cli_wrapper(tools_in_module)
345
+
346
+ # Strip __future__ annotations from source if present (we re-add it)
347
+ clean_source = _strip_future_annotations(source)
348
+
349
+ script_content = (
350
+ '"""Auto-generated tool script from Orchid agent configuration."""\n'
351
+ "from __future__ import annotations\n\n"
352
+ f"{clean_source}\n\n"
353
+ f"{cli_wrapper}\n"
354
+ )
355
+
356
+ (scripts_dir / script_name).write_text(script_content, encoding="utf-8")
357
+
358
+ # Build info for each tool in this module
359
+ for tool_name, tool_cfg in tools_in_module:
360
+ func_name = tool_cfg.handler.rsplit(".", 1)[1]
361
+ params = _extract_parameters(module_path, func_name)
362
+ usage_hint = _build_usage_hint(func_name, params)
363
+ result[tool_name] = _ToolScriptInfo(
364
+ script_name=script_name,
365
+ usage_hint=usage_hint,
366
+ parameters=params,
367
+ )
368
+
369
+ return result
370
+
371
+
372
+ def _read_module_source(module_path: str) -> str | None:
373
+ """Read the source code of a Python module by its dotted import path."""
374
+ try:
375
+ module = importlib.import_module(module_path)
376
+ source_file = inspect.getfile(module)
377
+ return Path(source_file).read_text(encoding="utf-8")
378
+ except Exception:
379
+ return None
380
+
381
+
382
+ def _strip_future_annotations(source: str) -> str:
383
+ """Remove 'from __future__ import annotations' to avoid duplication."""
384
+ lines = source.splitlines(keepends=True)
385
+ filtered = []
386
+ for line in lines:
387
+ stripped = line.strip()
388
+ if stripped == "from __future__ import annotations":
389
+ continue
390
+ filtered.append(line)
391
+ return "".join(filtered)
392
+
393
+
394
+ def _extract_parameters(module_path: str, func_name: str) -> dict[str, str]:
395
+ """Extract parameter names and descriptions from a function's signature and docstring."""
396
+ try:
397
+ module = importlib.import_module(module_path)
398
+ func = getattr(module, func_name)
399
+ sig = inspect.signature(func)
400
+ docstring = inspect.getdoc(func) or ""
401
+
402
+ params: dict[str, str] = {}
403
+ for name, param in sig.parameters.items():
404
+ if name in ("kwargs", "self", "cls"):
405
+ continue
406
+ # Try to find description in docstring Parameters section
407
+ desc = _find_param_doc(docstring, name)
408
+ if not desc:
409
+ # Fall back to annotation
410
+ ann = param.annotation
411
+ if ann != inspect.Parameter.empty:
412
+ desc = str(ann).replace("'", "")
413
+ else:
414
+ desc = "string"
415
+ params[name] = desc
416
+
417
+ return params
418
+ except Exception:
419
+ return {}
420
+
421
+
422
+ def _find_param_doc(docstring: str, param_name: str) -> str:
423
+ """Extract a parameter description from a NumPy-style docstring."""
424
+ lines = docstring.splitlines()
425
+ in_params = False
426
+ found_param = False
427
+ for line in lines:
428
+ stripped = line.strip()
429
+ if stripped in ("Parameters", "Parameters:"):
430
+ in_params = True
431
+ continue
432
+ if in_params and stripped.startswith("---"):
433
+ continue
434
+ if in_params and stripped in ("Returns", "Returns:", "Raises", "Raises:"):
435
+ break
436
+ if in_params:
437
+ # Look for "param_name : type" line
438
+ if stripped.startswith(param_name) and ":" in stripped:
439
+ found_param = True
440
+ continue
441
+ if found_param:
442
+ if stripped and not stripped[0].isalpha() or not stripped:
443
+ # Description line (indented) or blank
444
+ if stripped:
445
+ return stripped
446
+ break
447
+ else:
448
+ # Next parameter
449
+ break
450
+ return ""
451
+
452
+
453
+ def _build_usage_hint(func_name: str, params: dict[str, str]) -> str:
454
+ """Build a CLI usage hint like '--player_name "LeBron James"'."""
455
+ if not params:
456
+ return func_name
457
+ args = " ".join(f'--{name} "<{name}>"' for name in params)
458
+ return f"{func_name} {args}"
459
+
460
+
461
+ def _build_cli_wrapper(tools_in_module: list[tuple[str, BuiltinToolConfig]]) -> str:
462
+ """Build a __main__ CLI wrapper that dispatches to tool functions."""
463
+ func_entries: list[tuple[str, str]] = [] # (tool_name, func_name)
464
+ for tool_name, tool_cfg in tools_in_module:
465
+ func_name = tool_cfg.handler.rsplit(".", 1)[1]
466
+ func_entries.append((tool_name, func_name))
467
+
468
+ lines: list[str] = []
469
+ lines.append("# ── CLI wrapper (auto-generated) ──────────────────────────────")
470
+ lines.append("")
471
+ lines.append("")
472
+ lines.append("if __name__ == '__main__':")
473
+ lines.append(" import sys")
474
+ lines.append(" import json as _json")
475
+ lines.append("")
476
+ lines.append(" _TOOLS = {")
477
+ for tool_name, func_name in func_entries:
478
+ lines.append(f" '{func_name}': {func_name},")
479
+ lines.append(" }")
480
+ lines.append("")
481
+ tool_names_str = ", ".join(fn for _, fn in func_entries)
482
+ lines.append(" if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):")
483
+ lines.append(" print(f'Usage: python {sys.argv[0]} <tool_name> [--arg value ...]')")
484
+ lines.append(f" print('Available tools: {tool_names_str}')")
485
+ lines.append(" sys.exit(0)")
486
+ lines.append("")
487
+ lines.append(" _tool_name = sys.argv[1]")
488
+ lines.append(" if _tool_name not in _TOOLS:")
489
+ lines.append(" print(f'Unknown tool: {_tool_name}')")
490
+ lines.append(f" print('Available tools: {tool_names_str}')")
491
+ lines.append(" sys.exit(1)")
492
+ lines.append("")
493
+ lines.append(" # Parse --key value arguments")
494
+ lines.append(" _kwargs = {}")
495
+ lines.append(" _args = sys.argv[2:]")
496
+ lines.append(" _i = 0")
497
+ lines.append(" while _i < len(_args):")
498
+ lines.append(" if _args[_i].startswith('--') and _i + 1 < len(_args):")
499
+ lines.append(" _kwargs[_args[_i][2:]] = _args[_i + 1]")
500
+ lines.append(" _i += 2")
501
+ lines.append(" else:")
502
+ lines.append(" _i += 1")
503
+ lines.append("")
504
+ lines.append(" # Coerce argument types using function annotations")
505
+ lines.append(" import inspect as _inspect")
506
+ lines.append(" _sig = _inspect.signature(_TOOLS[_tool_name])")
507
+ lines.append(" _coerced = {}")
508
+ lines.append(" for _k, _v in _kwargs.items():")
509
+ lines.append(" _param = _sig.parameters.get(_k)")
510
+ lines.append(" if _param and _param.annotation != _inspect.Parameter.empty:")
511
+ lines.append(" _ann = _param.annotation")
512
+ lines.append(" if _ann in (int, 'int'):")
513
+ lines.append(" _v = int(_v)")
514
+ lines.append(" elif _ann in (float, 'float'):")
515
+ lines.append(" _v = float(_v)")
516
+ lines.append(" elif _ann in (bool, 'bool'):")
517
+ lines.append(" _v = _v.lower() in ('true', '1', 'yes')")
518
+ lines.append(" _coerced[_k] = _v")
519
+ lines.append("")
520
+ lines.append(" _result = _TOOLS[_tool_name](**_coerced)")
521
+ lines.append(" print(_json.dumps(_result, indent=2, default=str))")
522
+
523
+ return "\n".join(lines)
524
+
525
+
526
+ # ── Helpers ───────────────────────────────────────────────────────
527
+
528
+
529
+ def _clean_description(text: str) -> str:
530
+ """Collapse whitespace in a YAML multi-line description."""
531
+ return " ".join(text.split()).strip()
532
+
533
+
534
+ def _truncate(text: str, max_len: int) -> str:
535
+ """Truncate text with ellipsis if needed, escaping quotes for YAML."""
536
+ text = text.replace('"', '\\"')
537
+ if len(text) <= max_len:
538
+ return text
539
+ return text[: max_len - 1] + "…"
orchid_cli/main.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ Orchid CLI — command-line interface for the Orchid agent framework.
3
+
4
+ Usage:
5
+ orchid chat "What are LeBron's stats?" --config examples/basketball/orchid.yml
6
+ orchid chat --interactive --config examples/basketball/orchid.yml
7
+ orchid config validate examples/basketball/orchid.yml
8
+ orchid index --config examples/basketball/orchid.yml
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import typer
14
+
15
+ from .commands import chat, config, index, skill
16
+
17
+ app = typer.Typer(
18
+ name="orchid",
19
+ help="Orchid — multi-agent AI framework CLI",
20
+ no_args_is_help=True,
21
+ )
22
+
23
+ app.add_typer(chat.app, name="chat")
24
+ app.add_typer(config.app, name="config")
25
+ app.add_typer(index.app, name="index")
26
+ app.add_typer(skill.app, name="skill")
27
+
28
+
29
+ if __name__ == "__main__":
30
+ app()
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: orchid-cli
3
+ Version: 0.1.0
4
+ Summary: Orchid CLI — command-line interface for the Orchid agent framework
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: httpx>=0.28.0
8
+ Requires-Dist: langchain-core>=0.3.0
9
+ Requires-Dist: orchid-ai>=1.1.0
10
+ Requires-Dist: pydantic-settings>=2.7.0
11
+ Requires-Dist: pyyaml>=6.0
12
+ Requires-Dist: rich>=13.0
13
+ Requires-Dist: typer>=0.12.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: gitlint>=0.19.0; extra == 'dev'
16
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
17
+ Requires-Dist: pytest-asyncio>=0.25.0; extra == 'dev'
18
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
@@ -0,0 +1,13 @@
1
+ orchid_cli/__init__.py,sha256=TR3LU0f00o8wk6UNhveP9HkWru6m0HE5lup8hSkJVc0,13
2
+ orchid_cli/bootstrap.py,sha256=d00k4b_TAwcxyxudbrdqweLdifj1KjwbcrEihsVqH7A,6743
3
+ orchid_cli/main.py,sha256=KaSBSoSGvsQuR2XDtDqNwoAFjmZifU8P7zrmSrUn_L4,769
4
+ orchid_cli/commands/__init__.py,sha256=ZgebDddFSsH7OC0ZjpvzOouWrwfLdfWilzAwu5de8AE,23
5
+ orchid_cli/commands/chat.py,sha256=klC7mz1BkulZQnZxEPD30ZL0JWzUhK5B_BHrbakwXJE,16363
6
+ orchid_cli/commands/config.py,sha256=M6mtK7nYXfS_EZU8Mq4gWlnLDRoxaYtAbeDxk6VSG9A,1293
7
+ orchid_cli/commands/index.py,sha256=M_uS1LLq6mqewvemxCw2c7pg7r5djO0CQfhM0cj4Irs,1371
8
+ orchid_cli/commands/skill.py,sha256=k3EYGr8P1IO53Osg0kAVCttRCTuPAedbLI_qEqWwxS0,21175
9
+ orchid_cli-0.1.0.dist-info/METADATA,sha256=X-v0bkRW5ZoHil6YNTK1bAo4UM6Um5-HdqdH_ukm88c,699
10
+ orchid_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ orchid_cli-0.1.0.dist-info/entry_points.txt,sha256=lQMig0J_0tiSlmBTME0s-Xt9TMxCaDfbDeOdKH2gyk8,47
12
+ orchid_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Y7UF4tWj3YhozK8JcR7EqqEVYX1ZQSrNdII70UfMnVE,1076
13
+ orchid_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orchid = orchid_cli.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Orchid Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.