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+ import sys
9+ from humanloop import Humanloop
10+ from humanloop .sync .sync_client import SyncClient
11+ from datetime import datetime
12+ from humanloop .cli .progress import progress_context
13+
14+ # Set up logging
15+ logger = logging .getLogger (__name__ )
16+ logger .setLevel (logging .INFO ) # Set back to INFO level
17+ console_handler = logging .StreamHandler ()
18+ formatter = logging .Formatter ("%(message)s" ) # Simplified formatter
19+ console_handler .setFormatter (formatter )
20+ if not logger .hasHandlers ():
21+ logger .addHandler (console_handler )
22+
23+ # Color constants
24+ SUCCESS_COLOR = "green"
25+ ERROR_COLOR = "red"
26+ INFO_COLOR = "blue"
27+ WARNING_COLOR = "yellow"
28+
29+ MAX_FILES_TO_DISPLAY = 10
30+
31+ def get_client (api_key : Optional [str ] = None , env_file : Optional [str ] = None , base_url : Optional [str ] = None ) -> Humanloop :
32+ """Get a Humanloop client instance."""
33+ if not api_key :
34+ if env_file :
35+ load_dotenv (env_file )
36+ else :
37+ env_path = find_dotenv ()
38+ if env_path :
39+ load_dotenv (env_path )
40+ else :
41+ if os .path .exists (".env" ):
42+ load_dotenv (".env" )
43+ else :
44+ load_dotenv ()
45+
46+ api_key = os .getenv ("HUMANLOOP_API_KEY" )
47+ if not api_key :
48+ raise click .ClickException (
49+ click .style ("No API key found. Set HUMANLOOP_API_KEY in .env file or environment, or use --api-key" , fg = ERROR_COLOR )
50+ )
51+
52+ return Humanloop (api_key = api_key , base_url = base_url )
53+
54+ def common_options (f : Callable ) -> Callable :
55+ """Decorator for common CLI options."""
56+ @click .option (
57+ "--api-key" ,
58+ help = "Humanloop API key. If not provided, uses HUMANLOOP_API_KEY from .env or environment." ,
59+ default = None ,
60+ show_default = False ,
61+ )
62+ @click .option (
63+ "--env-file" ,
64+ help = "Path to .env file. If not provided, looks for .env in current directory." ,
65+ default = None ,
66+ type = click .Path (exists = True ),
67+ show_default = False ,
68+ )
69+ @click .option (
70+ "--base-dir" ,
71+ help = "Base directory for pulled files" ,
72+ default = "humanloop" ,
73+ type = click .Path (),
74+ )
75+ @click .option (
76+ "--base-url" ,
77+ default = None ,
78+ hidden = True ,
79+ )
80+ @wraps (f )
81+ def wrapper (* args , ** kwargs ):
82+ return f (* args , ** kwargs )
83+ return wrapper
84+
85+ def handle_sync_errors (f : Callable ) -> Callable :
86+ """Decorator for handling sync operation errors."""
87+ @wraps (f )
88+ def wrapper (* args , ** kwargs ):
89+ try :
90+ return f (* args , ** kwargs )
91+ except Exception as e :
92+ click .echo (click .style (str (f"Error: { e } " ), fg = ERROR_COLOR ))
93+ sys .exit (1 )
94+ return wrapper
95+
96+ @click .group (
97+ help = "Humanloop CLI for managing sync operations." ,
98+ context_settings = {
99+ "help_option_names" : ["-h" , "--help" ],
100+ "max_content_width" : 100 ,
101+ }
102+ )
103+ def cli ():
104+ """Humanloop CLI for managing sync operations."""
105+ pass
106+
107+ @cli .command ()
108+ @click .option (
109+ "--path" ,
110+ "-p" ,
111+ help = "Path to pull (file or directory). If not provided, pulls everything. "
112+ "To pull a specific file, ensure the extension for the file is included (e.g. .prompt or .agent). "
113+ "To pull a directory, simply specify the path to the directory (e.g. abc/def to pull all files under abc/def and its subdirectories)." ,
114+ default = None ,
115+ )
116+ @click .option (
117+ "--environment" ,
118+ "-e" ,
119+ help = "Environment to pull from (e.g. 'production', 'staging')" ,
120+ default = None ,
121+ )
122+ @click .option (
123+ "--verbose" ,
124+ "-v" ,
125+ is_flag = True ,
126+ help = "Show detailed information about the operation" ,
127+ )
128+ @handle_sync_errors
129+ @common_options
130+ def pull (
131+ path : Optional [str ],
132+ environment : Optional [str ],
133+ api_key : Optional [str ],
134+ env_file : Optional [str ],
135+ base_dir : str ,
136+ base_url : Optional [str ],
137+ verbose : bool
138+ ):
139+ """Pull prompt and agent files from Humanloop to your local filesystem.
140+
141+ \b
142+ This command will:
143+ 1. Fetch prompt and agent files from your Humanloop workspace
144+ 2. Save them to your local filesystem
145+ 3. Maintain the same directory structure as in Humanloop
146+ 4. Add appropriate file extensions (.prompt or .agent)
147+
148+ \b
149+ The files will be saved with the following structure:
150+ {base_dir}/
151+ ├── prompts/
152+ │ ├── my_prompt.prompt
153+ │ └── nested/
154+ │ └── another_prompt.prompt
155+ └── agents/
156+ └── my_agent.agent
157+
158+ The operation will overwrite existing files with the latest version from Humanloop
159+ but will not delete local files that don't exist in the remote workspace.
160+
161+ Currently only supports syncing prompt and agent files. Other file types will be skipped."""
162+ client = get_client (api_key , env_file , base_url )
163+ sync_client = SyncClient (client , base_dir = base_dir , log_level = logging .DEBUG if verbose else logging .WARNING )
164+
165+ click .echo (click .style ("Pulling files from Humanloop..." , fg = INFO_COLOR ))
166+ click .echo (click .style (f"Path: { path or '(root)' } " , fg = INFO_COLOR ))
167+ click .echo (click .style (f"Environment: { environment or '(default)' } " , fg = INFO_COLOR ))
168+
169+ if verbose :
170+ # Don't use the spinner in verbose mode as the spinner and sync client logging compete
171+ successful_files = sync_client .pull (path , environment )
172+ else :
173+ with progress_context ("Pulling files..." ):
174+ successful_files = sync_client .pull (path , environment )
175+
176+ # Get metadata about the operation
177+ metadata = sync_client .metadata .get_last_operation ()
178+ if metadata :
179+ # Determine if the operation was successful based on failed_files
180+ is_successful = not metadata .get ('failed_files' ) and not metadata .get ('error' )
181+ duration_color = SUCCESS_COLOR if is_successful else ERROR_COLOR
182+ click .echo (click .style (f"Pull completed in { metadata ['duration_ms' ]} ms" , fg = duration_color ))
183+
184+ if metadata ['successful_files' ]:
185+ click .echo (click .style (f"\n Successfully pulled { len (metadata ['successful_files' ])} files:" , fg = SUCCESS_COLOR ))
186+
187+ if verbose :
188+ for file in metadata ['successful_files' ]:
189+ click .echo (click .style (f" ✓ { file } " , fg = SUCCESS_COLOR ))
190+ else :
191+ files_to_display = metadata ['successful_files' ][:MAX_FILES_TO_DISPLAY ]
192+ for file in files_to_display :
193+ click .echo (click .style (f" ✓ { file } " , fg = SUCCESS_COLOR ))
194+
195+ if len (metadata ['successful_files' ]) > MAX_FILES_TO_DISPLAY :
196+ remaining = len (metadata ['successful_files' ]) - MAX_FILES_TO_DISPLAY
197+ click .echo (click .style (f" ...and { remaining } more" , fg = SUCCESS_COLOR ))
198+ if metadata ['failed_files' ]:
199+ click .echo (click .style (f"\n Failed to pull { len (metadata ['failed_files' ])} files:" , fg = ERROR_COLOR ))
200+ for file in metadata ['failed_files' ]:
201+ click .echo (click .style (f" ✗ { file } " , fg = ERROR_COLOR ))
202+ if metadata .get ('error' ):
203+ click .echo (click .style (f"\n Error: { metadata ['error' ]} " , fg = ERROR_COLOR ))
204+
205+ def format_timestamp (timestamp : str ) -> str :
206+ """Format timestamp to a more readable format."""
207+ try :
208+ dt = datetime .fromisoformat (timestamp .replace ('Z' , '+00:00' ))
209+ return dt .strftime ('%Y-%m-%d %H:%M:%S' )
210+ except (ValueError , AttributeError ):
211+ return timestamp
212+
213+ @cli .command ()
214+ @click .option (
215+ "--oneline" ,
216+ is_flag = True ,
217+ help = "Display history in a single line per operation" ,
218+ )
219+ @handle_sync_errors
220+ @common_options
221+ def history (api_key : Optional [str ], env_file : Optional [str ], base_dir : str , base_url : Optional [str ], oneline : bool ):
222+ """Show sync operation history."""
223+ client = get_client (api_key , env_file , base_url )
224+ sync_client = SyncClient (client , base_dir = base_dir )
225+
226+ history = sync_client .metadata .get_history ()
227+ if not history :
228+ click .echo (click .style ("No sync operations found in history." , fg = WARNING_COLOR ))
229+ return
230+
231+ if not oneline :
232+ click .echo (click .style ("Sync Operation History:" , fg = INFO_COLOR ))
233+ click .echo (click .style ("======================" , fg = INFO_COLOR ))
234+
235+ for op in history :
236+ if oneline :
237+ # Format: timestamp | operation_type | path | environment | duration_ms | status
238+ status = click .style ("✓" , fg = SUCCESS_COLOR ) if not op ['failed_files' ] else click .style ("✗" , fg = ERROR_COLOR )
239+ click .echo (f"{ format_timestamp (op ['timestamp' ])} | { op ['operation_type' ]} | { op ['path' ] or '(root)' } | { op ['environment' ] or '-' } | { op ['duration_ms' ]} ms | { status } " )
240+ else :
241+ click .echo (click .style (f"\n Operation: { op ['operation_type' ]} " , fg = INFO_COLOR ))
242+ click .echo (f"Timestamp: { format_timestamp (op ['timestamp' ])} " )
243+ click .echo (f"Path: { op ['path' ] or '(root)' } " )
244+ if op ['environment' ]:
245+ click .echo (f"Environment: { op ['environment' ]} " )
246+ click .echo (f"Duration: { op ['duration_ms' ]} ms" )
247+ if op ['successful_files' ]:
248+ click .echo (click .style (f"Successfully { op ['operation_type' ]} ed { len (op ['successful_files' ])} file{ '' if len (op ['successful_files' ]) == 1 else 's' } " , fg = SUCCESS_COLOR ))
249+ if op ['failed_files' ]:
250+ click .echo (click .style (f"Failed to { op ['operation_type' ]} ed { len (op ['failed_files' ])} file{ '' if len (op ['failed_files' ]) == 1 else 's' } " , fg = ERROR_COLOR ))
251+ if op ['error' ]:
252+ click .echo (click .style (f"Error: { op ['error' ]} " , fg = ERROR_COLOR ))
253+ click .echo (click .style ("----------------------" , fg = INFO_COLOR ))
254+
255+ if __name__ == "__main__" :
256+ cli ()
0 commit comments