1+ import click
2+ import logging
3+ from pathlib import Path
4+ from typing import Optional , Callable
5+ from functools import wraps
6+ from dotenv import load_dotenv , find_dotenv
7+ import os
8+ from humanloop import Humanloop
9+ from humanloop .sync .sync_client import SyncClient
10+
11+ # Set up logging
12+ logger = logging .getLogger (__name__ )
13+ logger .setLevel (logging .INFO ) # Set back to INFO level
14+ console_handler = logging .StreamHandler ()
15+ formatter = logging .Formatter ("%(message)s" ) # Simplified formatter
16+ console_handler .setFormatter (formatter )
17+ if not logger .hasHandlers ():
18+ logger .addHandler (console_handler )
19+
20+ def get_client (api_key : Optional [str ] = None , env_file : Optional [str ] = None , base_url : Optional [str ] = None ) -> Humanloop :
21+ """Get a Humanloop client instance."""
22+ if not api_key :
23+ if env_file :
24+ load_dotenv (env_file )
25+ else :
26+ env_path = find_dotenv ()
27+ if env_path :
28+ load_dotenv (env_path )
29+ else :
30+ if os .path .exists (".env" ):
31+ load_dotenv (".env" )
32+ else :
33+ load_dotenv ()
34+
35+ api_key = os .getenv ("HUMANLOOP_API_KEY" )
36+ if not api_key :
37+ raise click .ClickException (
38+ "No API key found. Set HUMANLOOP_API_KEY in .env file or environment, or use --api-key"
39+ )
40+
41+ return Humanloop (api_key = api_key , base_url = base_url )
42+
43+ def common_options (f : Callable ) -> Callable :
44+ """Decorator for common CLI options."""
45+ @click .option (
46+ "--api-key" ,
47+ help = "Humanloop API key. If not provided, uses HUMANLOOP_API_KEY from .env or environment." ,
48+ default = None ,
49+ )
50+ @click .option (
51+ "--env-file" ,
52+ help = "Path to .env file. If not provided, looks for .env in current directory." ,
53+ default = None ,
54+ type = click .Path (exists = True ),
55+ )
56+ @click .option (
57+ "--base-dir" ,
58+ help = "Base directory for synced files" ,
59+ default = "humanloop" ,
60+ type = click .Path (),
61+ )
62+ # Hidden option for internal use - allows overriding the Humanloop API base URL
63+ # Can be set via --base-url or HUMANLOOP_BASE_URL environment variable
64+ @click .option (
65+ "--base-url" ,
66+ default = None ,
67+ hidden = True ,
68+ )
69+ @wraps (f )
70+ def wrapper (* args , ** kwargs ):
71+ return f (* args , ** kwargs )
72+ return wrapper
73+
74+ def handle_sync_errors (f : Callable ) -> Callable :
75+ """Decorator for handling sync operation errors."""
76+ @wraps (f )
77+ def wrapper (* args , ** kwargs ):
78+ try :
79+ return f (* args , ** kwargs )
80+ except Exception as e :
81+ logger .error (f"Error during sync operation: { str (e )} " )
82+ raise click .ClickException (str (e ))
83+ return wrapper
84+
85+ @click .group ()
86+ def cli ():
87+ """Humanloop CLI for managing sync operations."""
88+ pass
89+
90+ @cli .command ()
91+ @click .option (
92+ "--path" ,
93+ "-p" ,
94+ help = "Path to pull (file or directory). If not provided, pulls everything." ,
95+ default = None ,
96+ )
97+ @click .option (
98+ "--environment" ,
99+ "-e" ,
100+ help = "Environment to pull from (e.g. 'production', 'staging')" ,
101+ default = None ,
102+ )
103+ @common_options
104+ @handle_sync_errors
105+ def pull (path : Optional [str ], environment : Optional [str ], api_key : Optional [str ], env_file : Optional [str ], base_dir : str , base_url : Optional [str ]):
106+ """Pull files from Humanloop to local filesystem.
107+
108+ If PATH is provided and ends with .prompt or .agent, pulls that specific file.
109+ Otherwise, pulls all files under the specified directory path.
110+ If no PATH is provided, pulls all files from the root.
111+ """
112+ client = get_client (api_key , env_file , base_url )
113+ sync_client = SyncClient (client , base_dir = base_dir )
114+
115+ click .echo ("Pulling files from Humanloop..." )
116+
117+ click .echo (f"Path: { path or '(root)' } " )
118+ click .echo (f"Environment: { environment or '(default)' } " )
119+
120+ successful_files = sync_client .pull (path , environment )
121+
122+ # Get metadata about the operation
123+ metadata = sync_client .metadata .get_last_operation ()
124+ if metadata :
125+ click .echo (f"\n Sync completed in { metadata ['duration_ms' ]} ms" )
126+ if metadata ['successful_files' ]:
127+ click .echo (f"\n Successfully synced { len (metadata ['successful_files' ])} files:" )
128+ for file in metadata ['successful_files' ]:
129+ click .echo (f" ✓ { file } " )
130+ if metadata ['failed_files' ]:
131+ click .echo (f"\n Failed to sync { len (metadata ['failed_files' ])} files:" )
132+ for file in metadata ['failed_files' ]:
133+ click .echo (f" ✗ { file } " )
134+
135+ @cli .command ()
136+ @common_options
137+ @handle_sync_errors
138+ def history (api_key : Optional [str ], env_file : Optional [str ], base_dir : str , base_url : Optional [str ]):
139+ """Show sync operation history."""
140+ client = get_client (api_key , env_file , base_url )
141+ sync_client = SyncClient (client , base_dir = base_dir )
142+
143+ history = sync_client .metadata .get_history ()
144+ if not history :
145+ click .echo ("No sync operations found in history." )
146+ return
147+
148+ click .echo ("Sync Operation History:" )
149+ click .echo ("======================" )
150+
151+ for op in history :
152+ click .echo (f"\n Operation: { op ['operation_type' ]} " )
153+ click .echo (f"Timestamp: { op ['timestamp' ]} " )
154+ click .echo (f"Path: { op ['path' ] or '(root)' } " )
155+ if op ['environment' ]:
156+ click .echo (f"Environment: { op ['environment' ]} " )
157+ click .echo (f"Duration: { op ['duration_ms' ]} ms" )
158+ if op ['successful_files' ]:
159+ click .echo (f"Successfully synced { len (op ['successful_files' ])} file{ '' if len (op ['successful_files' ]) == 1 else 's' } " )
160+ if op ['failed_files' ]:
161+ click .echo (f"Failed to sync { len (op ['failed_files' ])} file{ '' if len (op ['failed_files' ]) == 1 else 's' } " )
162+ if op ['error' ]:
163+ click .echo (f"Error: { op ['error' ]} " )
164+ click .echo ("----------------------" )
165+
166+ if __name__ == "__main__" :
167+ cli ()
0 commit comments