|
1 | 1 | #!/usr/bin/env node |
2 | 2 | import * as dotenv from "dotenv"; |
3 | 3 | import { Command } from "commander"; |
| 4 | +import path from "path"; |
4 | 5 |
|
5 | 6 | import { HumanloopClient } from "./humanloop.client"; |
6 | | -import Logger from "./utils/Logger"; |
| 7 | +import FileSyncer from "./sync/FileSyncer"; |
| 8 | +import { SDK_VERSION } from "./version"; |
7 | 9 |
|
8 | | -const { version } = require("../package.json"); |
9 | | - |
10 | | -// Load environment variables |
11 | 10 | dotenv.config(); |
12 | 11 |
|
| 12 | +const LogType = { |
| 13 | + SUCCESS: "\x1b[92m", // green |
| 14 | + ERROR: "\x1b[91m", // red |
| 15 | + INFO: "\x1b[96m", // cyan |
| 16 | + WARN: "\x1b[93m", // yellow |
| 17 | + RESET: "\x1b[0m", |
| 18 | +} as const; |
| 19 | + |
| 20 | +function log(message: string, type: keyof typeof LogType): void { |
| 21 | + console.log(`${LogType[type]}${message}${LogType.RESET}`); |
| 22 | +} |
| 23 | + |
13 | 24 | const program = new Command(); |
14 | 25 | program |
15 | 26 | .name("humanloop") |
16 | 27 | .description("Humanloop CLI for managing sync operations") |
17 | | - .version(version); |
| 28 | + .version(SDK_VERSION); |
| 29 | + |
| 30 | +interface CommonOptions { |
| 31 | + apiKey?: string; |
| 32 | + envFile?: string; |
| 33 | + baseUrl?: string; |
| 34 | + localFilesDirectory?: string; |
| 35 | +} |
| 36 | + |
| 37 | +interface PullOptions extends CommonOptions { |
| 38 | + path?: string; |
| 39 | + environment?: string; |
| 40 | + verbose?: boolean; |
| 41 | + quiet?: boolean; |
| 42 | +} |
18 | 43 |
|
19 | | -// Common auth options |
20 | | -const addAuthOptions = (command: Command) => |
| 44 | +const addCommonOptions = (command: Command) => |
21 | 45 | command |
22 | 46 | .option("--api-key <apiKey>", "Humanloop API key") |
23 | 47 | .option("--env-file <envFile>", "Path to .env file") |
24 | | - .option("--base-url <baseUrl>", "Base URL for Humanloop API"); |
| 48 | + .option("--base-url <baseUrl>", "Base URL for Humanloop API") |
| 49 | + .option( |
| 50 | + "--local-dir, --local-files-directory <dir>", |
| 51 | + "Directory where Humanloop files are stored locally (default: humanloop/)", |
| 52 | + "humanloop", |
| 53 | + ); |
| 54 | + |
| 55 | +// Instantiate a HumanloopClient for the CLI |
| 56 | +function getClient(options: CommonOptions): HumanloopClient { |
| 57 | + if (options.envFile) { |
| 58 | + const result = dotenv.config({ path: options.envFile }); |
| 59 | + if (result.error) { |
| 60 | + log( |
| 61 | + `Failed to load environment file: ${options.envFile} (file not found or invalid format)`, |
| 62 | + "ERROR", |
| 63 | + ); |
| 64 | + process.exit(1); |
| 65 | + } |
| 66 | + } |
25 | 67 |
|
26 | | -// Helper to get client |
27 | | -function getClient(options: { |
28 | | - envFile?: string; |
29 | | - apiKey?: string; |
30 | | - baseUrl?: string; |
31 | | - baseDir?: string; |
32 | | -}): HumanloopClient { |
33 | | - if (options.envFile) dotenv.config({ path: options.envFile }); |
34 | 68 | const apiKey = options.apiKey || process.env.HUMANLOOP_API_KEY; |
35 | 69 | if (!apiKey) { |
36 | | - Logger.error( |
37 | | - "No API key found. Set HUMANLOOP_API_KEY in .env file or use --api-key", |
| 70 | + log( |
| 71 | + "No API key found. Set HUMANLOOP_API_KEY in .env file or environment, or use --api-key", |
| 72 | + "ERROR", |
38 | 73 | ); |
39 | 74 | process.exit(1); |
40 | 75 | } |
| 76 | + |
41 | 77 | return new HumanloopClient({ |
42 | 78 | apiKey, |
43 | 79 | baseUrl: options.baseUrl, |
44 | | - sync: { baseDir: options.baseDir }, |
| 80 | + localFilesDirectory: options.localFilesDirectory, |
45 | 81 | }); |
46 | 82 | } |
47 | 83 |
|
| 84 | +// Helper to handle sync errors |
| 85 | +function handleSyncErrors<T extends CommonOptions>(fn: (options: T) => Promise<void>) { |
| 86 | + return async (options: T) => { |
| 87 | + try { |
| 88 | + await fn(options); |
| 89 | + } catch (error) { |
| 90 | + log(`Error: ${error}`, "ERROR"); |
| 91 | + process.exit(1); |
| 92 | + } |
| 93 | + }; |
| 94 | +} |
| 95 | + |
48 | 96 | // Pull command |
49 | | -addAuthOptions( |
| 97 | +addCommonOptions( |
50 | 98 | program |
51 | 99 | .command("pull") |
52 | | - .description("Pull files from Humanloop to local filesystem") |
53 | | - .option("-p, --path <path>", "Path to pull (file or directory)") |
54 | | - .option("-e, --environment <env>", "Environment to pull from") |
55 | | - .option("--base-dir <baseDir>", "Base directory for synced files", "humanloop"), |
56 | | -).action(async (options) => { |
57 | | - Logger.info("Pulling files from Humanloop..."); |
58 | | - // try { |
59 | | - // Logger.info("Pulling files from Humanloop..."); |
60 | | - // const client = getClient(options); |
61 | | - // const files = await client.pull(options.path, options.environment); |
62 | | - // Logger.success(`Successfully synced ${files.length} files`); |
63 | | - // } catch (error) { |
64 | | - // Logger.error(`Error: ${error}`); |
65 | | - // process.exit(1); |
66 | | - // } |
67 | | -}); |
| 100 | + .description( |
| 101 | + "Pull Prompt and Agent files from Humanloop to your local filesystem.\n\n" + |
| 102 | + "This command will:\n" + |
| 103 | + "1. Fetch Prompt and Agent files from your Humanloop workspace\n" + |
| 104 | + "2. Save them to your local filesystem (directory specified by --local-files-directory, default: humanloop/)\n" + |
| 105 | + "3. Maintain the same directory structure as in Humanloop\n" + |
| 106 | + "4. Add appropriate file extensions (.prompt or .agent)\n\n" + |
| 107 | + "For example, with the default --local-files-directory=humanloop, files will be saved as:\n" + |
| 108 | + "./humanloop/\n" + |
| 109 | + "├── my_project/\n" + |
| 110 | + "│ ├── prompts/\n" + |
| 111 | + "│ │ ├── my_prompt.prompt\n" + |
| 112 | + "│ │ └── nested/\n" + |
| 113 | + "│ │ └── another_prompt.prompt\n" + |
| 114 | + "│ └── agents/\n" + |
| 115 | + "│ └── my_agent.agent\n" + |
| 116 | + "└── another_project/\n" + |
| 117 | + " └── prompts/\n" + |
| 118 | + " └── other_prompt.prompt\n\n" + |
| 119 | + "If you specify --local-files-directory=data/humanloop, files will be saved in ./data/humanloop/ instead.\n\n" + |
| 120 | + "If a file exists both locally and in the Humanloop workspace, the local file will be overwritten\n" + |
| 121 | + "with the version from Humanloop. Files that only exist locally will not be affected.\n\n" + |
| 122 | + "Currently only supports syncing Prompt and Agent files. Other file types will be skipped.", |
| 123 | + ) |
| 124 | + .option( |
| 125 | + "-p, --path <path>", |
| 126 | + "Path in the Humanloop workspace to pull from (file or directory). " + |
| 127 | + "You can pull an entire directory (e.g. 'my/directory') or a specific file (e.g. 'my/directory/my_prompt.prompt'). " + |
| 128 | + "When pulling a directory, all files within that directory and its subdirectories will be included. " + |
| 129 | + "Paths should not contain leading or trailing slashes. " + |
| 130 | + "If not specified, pulls from the root of the remote workspace.", |
| 131 | + ) |
| 132 | + .option( |
| 133 | + "-e, --environment <env>", |
| 134 | + "Environment to pull from (e.g. 'production', 'staging')", |
| 135 | + ) |
| 136 | + .option("-v, --verbose", "Show detailed information about the operation") |
| 137 | + .option("-q, --quiet", "Suppress output of successful files"), |
| 138 | +).action( |
| 139 | + handleSyncErrors(async (options: PullOptions) => { |
| 140 | + const client = getClient(options); |
| 141 | + |
| 142 | + // Create a separate FileSyncer instance with log level based on verbose flag only |
| 143 | + const fileSyncer = new FileSyncer(client, { |
| 144 | + baseDir: options.localFilesDirectory, |
| 145 | + verbose: options.verbose, |
| 146 | + }); |
| 147 | + |
| 148 | + log("Pulling files from Humanloop...", "INFO"); |
| 149 | + log(`Path: ${options.path || "(root)"}`, "INFO"); |
| 150 | + log(`Environment: ${options.environment || "(default)"}`, "INFO"); |
| 151 | + |
| 152 | + const startTime = Date.now(); |
| 153 | + const [successfulFiles, failedFiles] = await fileSyncer.pull( |
| 154 | + options.path, |
| 155 | + options.environment, |
| 156 | + ); |
| 157 | + const duration = Date.now() - startTime; |
| 158 | + |
| 159 | + // Always show operation result |
| 160 | + const isSuccessful = failedFiles.length === 0; |
| 161 | + log(`Pull completed in ${duration}ms`, isSuccessful ? "SUCCESS" : "ERROR"); |
| 162 | + |
| 163 | + // Only suppress successful files output if quiet flag is set |
| 164 | + if (successfulFiles.length > 0 && !options.quiet) { |
| 165 | + console.log(); // Empty line |
| 166 | + log(`Successfully pulled ${successfulFiles.length} files:`, "SUCCESS"); |
| 167 | + for (const file of successfulFiles) { |
| 168 | + log(` ✓ ${file}`, "SUCCESS"); |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + // Always show failed files |
| 173 | + if (failedFiles.length > 0) { |
| 174 | + console.log(); // Empty line |
| 175 | + log(`Failed to pull ${failedFiles.length} files:`, "ERROR"); |
| 176 | + for (const file of failedFiles) { |
| 177 | + log(` ✗ ${file}`, "ERROR"); |
| 178 | + } |
| 179 | + } |
| 180 | + }), |
| 181 | +); |
68 | 182 |
|
69 | 183 | program.parse(process.argv); |
0 commit comments