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
+201 -7
View File
@@ -8,12 +8,31 @@ Usage:
opus generate --concept "Your story idea" opus generate --concept "Your story idea"
opus serve --port 8000 # Start API server opus serve --port 8000 # Start API server
opus docs # Show documentation 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 argparse
import asyncio import asyncio
import os import os
import sys import sys
import json
import requests
from pathlib import Path from pathlib import Path
# Add the project root to the path # Add the project root to the path
@@ -26,6 +45,123 @@ env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path) 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: def setup_cli() -> argparse.ArgumentParser:
"""Set up the CLI argument parser.""" """Set up the CLI argument parser."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -38,8 +174,7 @@ and PydanticAI for professional manuscript production.
Examples: Examples:
opus generate --concept "A robot dreams of love" --framework snowflake opus generate --concept "A robot dreams of love" --framework snowflake
opus serve --port 8080 opus serve --port 8080
opus docs opus --api-url http://localhost:8000 generate --concept "..."
opus ingest --repo mrhavens/my-book
""", """,
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
@@ -50,6 +185,12 @@ Examples:
version="Opus Orchestrator AI v0.2.0", 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") subparsers = parser.add_subparsers(dest="command", help="Command to run")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -293,6 +434,40 @@ async def run_generate(args: argparse.Namespace) -> int:
print("📚 OPUS ORCHESTRATOR AI") print("📚 OPUS ORCHESTRATOR AI")
print(f"{'='*60}\n") print(f"{'='*60}\n")
# Check for API client mode
if args.api_url:
client = OpusAPIClient(args.api_url)
print(f"🌐 API Client Mode")
print(f" Server: {args.api_url}\n")
# Call API
try:
result = client.generate(
concept=args.concept,
repo=args.repo,
framework=args.framework,
genre=args.genre,
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,
)
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:
# LOCAL MODE - run locally
# Determine the seed concept # Determine the seed concept
seed_concept = args.concept seed_concept = args.concept
@@ -321,6 +496,7 @@ async def run_generate(args: argparse.Namespace) -> int:
return 1 return 1
# Show generation parameters # Show generation parameters
print(f"🏠 Local Mode")
print(f"🎯 Generating {args.words:,} words ({args.chapters} chapters)") print(f"🎯 Generating {args.words:,} words ({args.chapters} chapters)")
print(f" Framework: {args.framework}") print(f" Framework: {args.framework}")
print(f" Genre: {args.genre}") print(f" Genre: {args.genre}")
@@ -425,22 +601,40 @@ def run_ingest(args: argparse.Namespace) -> int:
print(f"\n📥 Ingesting from GitHub: {args.repo}\n") print(f"\n📥 Ingesting from GitHub: {args.repo}\n")
# 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") orch = OpusOrchestrator(book_type="fiction")
content = orch.ingest_from_github(args.repo, include_readme=args.include_readme) 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"✅ Loaded {len(content_text):,} characters")
print(f" Files: {content.metadata['file_count']}") print(f" Files: {file_count}")
print(f" File list: {', '.join(content.metadata['files'])}\n") print(f" File list: {', '.join(files)}\n")
if args.preview: if args.preview:
print("📄 PREVIEW (first 2000 chars):") print("📄 PREVIEW (first 2000 chars):")
print("-" * 40) print("-" * 40)
print(content.text[:2000]) print(content_text[:2000])
print("-" * 40) print("-" * 40)
if args.output: if args.output:
with open(args.output, "w") as f: with open(args.output, "w") as f:
f.write(content.text) f.write(content_text)
print(f"\n💾 Saved to: {args.output}") print(f"\n💾 Saved to: {args.output}")
return 0 return 0