Skip to content

Commit efa682b

Browse files
committed
Match CLI functionality with Python SDK; Simplify logging
1 parent b690578 commit efa682b

7 files changed

Lines changed: 244 additions & 317 deletions

File tree

.fernignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ src/context.ts
1313
src/cli.ts
1414
src/cache
1515
src/sync
16-
src/utils
1716

1817
# Tests
1918

src/cli.ts

Lines changed: 150 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,183 @@
11
#!/usr/bin/env node
22
import * as dotenv from "dotenv";
33
import { Command } from "commander";
4+
import path from "path";
45

56
import { HumanloopClient } from "./humanloop.client";
6-
import Logger from "./utils/Logger";
7+
import FileSyncer from "./sync/FileSyncer";
8+
import { SDK_VERSION } from "./version";
79

8-
const { version } = require("../package.json");
9-
10-
// Load environment variables
1110
dotenv.config();
1211

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+
1324
const program = new Command();
1425
program
1526
.name("humanloop")
1627
.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+
}
1843

19-
// Common auth options
20-
const addAuthOptions = (command: Command) =>
44+
const addCommonOptions = (command: Command) =>
2145
command
2246
.option("--api-key <apiKey>", "Humanloop API key")
2347
.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+
}
2567

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 });
3468
const apiKey = options.apiKey || process.env.HUMANLOOP_API_KEY;
3569
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",
3873
);
3974
process.exit(1);
4075
}
76+
4177
return new HumanloopClient({
4278
apiKey,
4379
baseUrl: options.baseUrl,
44-
sync: { baseDir: options.baseDir },
80+
localFilesDirectory: options.localFilesDirectory,
4581
});
4682
}
4783

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+
4896
// Pull command
49-
addAuthOptions(
97+
addCommonOptions(
5098
program
5199
.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+
);
68182

69183
program.parse(process.argv);

src/humanloop.client.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ import {
2929
import { HumanloopSpanExporter } from "./otel/exporter";
3030
import { HumanloopSpanProcessor } from "./otel/processor";
3131
import { overloadCall, overloadLog } from "./overload";
32-
import { FileSyncerOptions, SyncClient } from "./sync";
33-
import Logger from "./utils/Logger";
32+
import { SyncClient } from "./sync";
3433
import { SDK_VERSION } from "./version";
3534

3635
const RED = "\x1b[91m";
@@ -287,7 +286,7 @@ export class HumanloopClient extends BaseHumanloopClient {
287286

288287
// Warn user if cacheSize is non-default but useLocalFiles is false
289288
if (!this.useLocalFiles && options.cacheSize !== undefined) {
290-
Logger.warn(
289+
console.warn(
291290
`The specified cacheSize=${options.cacheSize} will have no effect because useLocalFiles=false. ` +
292291
`File caching is only active when local files are enabled.`,
293292
);

0 commit comments

Comments
 (0)