Add API client mode to CLI

Now CLI works in both local and client/server mode:

# Local mode (default)
opus generate --concept ...

# API client mode
opus --api-url http://localhost:8000 generate --concept ...
opus --api-url https://opus-api.example.com generate --repo owner/repo

OpusAPIClient class for programmatic API access.
This commit is contained in:
2026-03-13 03:21:19 +00:00
parent c248487d2e
commit 86dcb5e8f9
+276 -82
View File
@@ -8,12 +8,31 @@ Usage:
opus generate --concept "Your story idea"
opus serve --port 8000 # Start API server
opus docs # Show documentation
"""
# Or use as API client:
opus --api-url http://localhost:8000 generate --concept "..."
Local mode (default): Runs generation locally using LangGraph/CrewAI
API mode: Sends requests to Opus API server
Usage:
opus [GLOBAL_OPTIONS] <command> [OPTIONS]
Examples:
# Local generation
opus generate --concept "A robot dreams of love"
# API client mode
opus --api-url http://localhost:8000 generate --concept "..."
opus --api-url https://opus-api.example.com generate --repo owner/repo
"""
import argparse
import asyncio
import os
import sys
import json
import requests
from pathlib import Path
# Add the project root to the path
@@ -26,6 +45,123 @@ env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path)
# =============================================================================
# API CLIENT
# =============================================================================
class OpusAPIClient:
"""Client for Opus REST API."""
def __init__(self, base_url: str):
"""Initialize API client.
Args:
base_url: Base URL of Opus API server
"""
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
def health(self) -> dict:
"""Check API health."""
return self._get("/health")
def frameworks(self) -> dict:
"""List available frameworks."""
return self._get("/frameworks")
def generate(
self,
concept: str = None,
repo: str = None,
framework: str = "snowflake",
genre: str = "fiction",
book_type: str = "fiction",
target_word_count: int = 5000,
chapters: int = 3,
tone: str = "literary",
use_crewai: bool = False,
use_autogen: bool = True,
) -> dict:
"""Generate a manuscript.
Args:
concept: Seed concept
repo: GitHub repo to ingest
framework: Story framework
genre: Genre
book_type: Book type
target_word_count: Target word count
chapters: Number of chapters
tone: Writing tone
use_crewai: Use CrewAI
use_autogen: Use AutoGen critique
Returns:
Generation result dict
"""
payload = {
"framework": framework,
"genre": genre,
"book_type": book_type,
"target_word_count": target_word_count,
"chapters": chapters,
"tone": tone,
"use_crewai": use_crewai,
"use_autogen": use_autogen,
}
if concept:
payload["concept"] = concept
if repo:
payload["repo"] = repo
return self._post("/generate", payload)
def ingest(self, repo: str, include_readme: bool = True) -> dict:
"""Ingest from GitHub.
Args:
repo: GitHub repo (owner/repo)
include_readme: Include README files
Returns:
Ingested content
"""
return self._post("/ingest", {
"repo": repo,
"include_readme": include_readme,
})
def _get(self, endpoint: str) -> dict:
"""GET request."""
url = f"{self.base_url}{endpoint}"
response = self.session.get(url)
response.raise_for_status()
return response.json()
def _post(self, endpoint: str, data: dict) -> dict:
"""POST request."""
url = f"{self.base_url}{endpoint}"
response = self.session.post(url, json=data)
response.raise_for_status()
return response.json()
def get_api_client(api_url: str = None) -> OpusAPIClient | None:
"""Get API client if URL provided.
Args:
api_url: API base URL
Returns:
OpusAPIClient or None
"""
if api_url:
return OpusAPIClient(api_url)
return None
def setup_cli() -> argparse.ArgumentParser:
"""Set up the CLI argument parser."""
parser = argparse.ArgumentParser(
@@ -38,8 +174,7 @@ and PydanticAI for professional manuscript production.
Examples:
opus generate --concept "A robot dreams of love" --framework snowflake
opus serve --port 8080
opus docs
opus ingest --repo mrhavens/my-book
opus --api-url http://localhost:8000 generate --concept "..."
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
@@ -50,6 +185,12 @@ Examples:
version="Opus Orchestrator AI v0.2.0",
)
# Global option for API client mode
parser.add_argument(
"--api-url",
help="Use API server at this URL (client mode). Without this, runs locally.",
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
# -------------------------------------------------------------------------
@@ -293,86 +434,121 @@ async def run_generate(args: argparse.Namespace) -> int:
print("📚 OPUS ORCHESTRATOR AI")
print(f"{'='*60}\n")
# Determine the seed concept
seed_concept = args.concept
if args.repo:
# Ingest from GitHub - use FULL content
print(f"📥 Ingesting from GitHub: {args.repo}")
# Check for API client mode
if args.api_url:
client = OpusAPIClient(args.api_url)
orch = OpusOrchestrator(
book_type=args.book_type,
genre=args.genre,
target_word_count=args.words,
framework=args.framework,
)
print(f"🌐 API Client Mode")
print(f" Server: {args.api_url}\n")
content = orch.ingest_from_github(args.repo)
# Use full content as seed
full_text = content.text
print(f" ✅ Loaded {len(full_text):,} characters from {content.metadata['file_count']} files")
print(f" 📄 Files: {', '.join(content.metadata['files'])}\n")
seed_concept = full_text
if not seed_concept:
print("Error: Please provide --concept or --repo")
return 1
# Show generation parameters
print(f"🎯 Generating {args.words:,} words ({args.chapters} chapters)")
print(f" Framework: {args.framework}")
print(f" Genre: {args.genre}")
print(f" Type: {args.book_type}")
print(f" Tone: {args.tone}")
print(f" CrewAI: {args.use_crewai}")
print(f" AutoGen: {not args.no_autogen}")
print()
use_autogen = not args.no_autogen
if args.use_crewai:
# Use CrewAI crews
print("🛠️ Using CrewAI crews...\n")
if args.book_type == "fiction":
crew = create_fiction_crew(
# Call API
try:
result = client.generate(
concept=args.concept,
repo=args.repo,
framework=args.framework,
genre=args.genre,
tone=args.tone,
target_word_count=args.words // args.chapters,
)
story = crew.write_full_story(
story_outline=seed_concept[:10000], # Limit for crew context
character_sheets="",
style_guide=f"Tone: {args.tone}",
num_chapters=args.chapters,
)
manuscript = "\n\n---\n\n".join(story)
else:
crew = create_nonfiction_crew(
topic=args.genre,
tone=args.tone,
book_type=args.book_type,
target_word_count=args.words,
chapters=args.chapters,
tone=args.tone,
use_crewai=args.use_crewai,
use_autogen=not args.no_autogen,
)
manuscript = crew.write_section(
section_outline=seed_concept[:10000],
style_guide=f"Tone: {args.tone}",
)
print(f"✅ Generation complete!")
print(f" Words: {result.get('word_count', 'N/A'):,}")
print(f" Chapters: {result.get('chapters', 'N/A')}")
print(f" Framework: {result.get('framework', 'N/A')}\n")
manuscript = result.get("manuscript", "")
except Exception as e:
print(f"❌ API Error: {e}")
return 1
else:
# Use LangGraph pipeline
result = await run_opus(
seed_concept=seed_concept,
framework=args.framework,
genre=args.genre,
target_word_count=args.words,
use_autogen=use_autogen,
)
# LOCAL MODE - run locally
# Determine the seed concept
seed_concept = args.concept
manuscript = result.get("manuscript", str(result))
if args.repo:
# Ingest from GitHub - use FULL content
print(f"📥 Ingesting from GitHub: {args.repo}")
orch = OpusOrchestrator(
book_type=args.book_type,
genre=args.genre,
target_word_count=args.words,
framework=args.framework,
)
content = orch.ingest_from_github(args.repo)
# Use full content as seed
full_text = content.text
print(f" ✅ Loaded {len(full_text):,} characters from {content.metadata['file_count']} files")
print(f" 📄 Files: {', '.join(content.metadata['files'])}\n")
seed_concept = full_text
if not seed_concept:
print("Error: Please provide --concept or --repo")
return 1
# Show generation parameters
print(f"🏠 Local Mode")
print(f"🎯 Generating {args.words:,} words ({args.chapters} chapters)")
print(f" Framework: {args.framework}")
print(f" Genre: {args.genre}")
print(f" Type: {args.book_type}")
print(f" Tone: {args.tone}")
print(f" CrewAI: {args.use_crewai}")
print(f" AutoGen: {not args.no_autogen}")
print()
use_autogen = not args.no_autogen
if args.use_crewai:
# Use CrewAI crews
print("🛠️ Using CrewAI crews...\n")
if args.book_type == "fiction":
crew = create_fiction_crew(
genre=args.genre,
tone=args.tone,
target_word_count=args.words // args.chapters,
)
story = crew.write_full_story(
story_outline=seed_concept[:10000], # Limit for crew context
character_sheets="",
style_guide=f"Tone: {args.tone}",
num_chapters=args.chapters,
)
manuscript = "\n\n---\n\n".join(story)
else:
crew = create_nonfiction_crew(
topic=args.genre,
tone=args.tone,
target_word_count=args.words,
)
manuscript = crew.write_section(
section_outline=seed_concept[:10000],
style_guide=f"Tone: {args.tone}",
)
else:
# Use LangGraph pipeline
result = await run_opus(
seed_concept=seed_concept,
framework=args.framework,
genre=args.genre,
target_word_count=args.words,
use_autogen=use_autogen,
)
manuscript = result.get("manuscript", str(result))
# Save output
output_path = args.output
@@ -425,22 +601,40 @@ def run_ingest(args: argparse.Namespace) -> int:
print(f"\n📥 Ingesting from GitHub: {args.repo}\n")
orch = OpusOrchestrator(book_type="fiction")
content = orch.ingest_from_github(args.repo, include_readme=args.include_readme)
# Check for API client mode
if args.api_url:
client = OpusAPIClient(args.api_url)
print(f"🌐 API Client Mode: {args.api_url}\n")
try:
result = client.ingest(args.repo, include_readme=args.include_readme)
content_text = result.get("content", "")
file_count = result.get("file_count", 0)
files = result.get("files", [])
except Exception as e:
print(f"❌ API Error: {e}")
return 1
else:
# Local mode
orch = OpusOrchestrator(book_type="fiction")
content = orch.ingest_from_github(args.repo, include_readme=args.include_readme)
content_text = content.text
file_count = content.metadata["file_count"]
files = content.metadata["files"]
print(f"✅ Loaded {len(content.text):,} characters")
print(f" Files: {content.metadata['file_count']}")
print(f" File list: {', '.join(content.metadata['files'])}\n")
print(f"✅ Loaded {len(content_text):,} characters")
print(f" Files: {file_count}")
print(f" File list: {', '.join(files)}\n")
if args.preview:
print("📄 PREVIEW (first 2000 chars):")
print("-" * 40)
print(content.text[:2000])
print(content_text[:2000])
print("-" * 40)
if args.output:
with open(args.output, "w") as f:
f.write(content.text)
f.write(content_text)
print(f"\n💾 Saved to: {args.output}")
return 0