Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# AGENTS.md

## Cursor Cloud specific instructions

### Overview

This is `langfuse-cli` — a CLI tool for interacting with the Langfuse API. It wraps the [`specli`](https://www.npmjs.com/package/specli) library which auto-generates CLI commands from a bundled OpenAPI specification (`openapi.yml`).

### Prerequisites

- **Bun** — required for build scripts (`bun run build`, `bun run patch-openapi`).
- **Node.js >= 20** — runtime for the built CLI (`bin/langfuse.mjs`).

### Key commands

| Task | Command |
|---|---|
| Install deps | `bun install` |
| Build | `bun run build` (fetches latest OpenAPI spec + bundles `src/cli.ts` → `dist/cli.js`) |
| Run CLI | `node bin/langfuse.mjs` |
| Patch OpenAPI only | `bun run patch-openapi` |
| Refetch OpenAPI | `bun run refetch-openapi` |

### Development notes

- There are no automated tests, linter, or formatter configured in this project.
- The build step (`bun run build`) fetches the OpenAPI spec from `https://cloud.langfuse.com/generated/api/openapi.yml` — it requires network access.
- To test the CLI against the Langfuse API you need `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` env vars (or pass `--public-key` / `--secret-key` flags). See `.env.default` for local dev defaults.
- The `--curl` flag on any API command previews the curl command without executing it, useful for verifying command construction without credentials.
- The project has no lockfile committed; `bun install` generates `bun.lock` locally.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ langfuse api score-v2s get-scores --limit 20

## Agent Usage

Install the official Langfuse AI skill:

```sh
npx skills add langfuse/skills --skill "langfuse"
```

A skill file is included for teaching AI agents how to use the CLI. Print it with:

```sh
Expand All @@ -85,6 +91,12 @@ langfuse get-skill

Pipe it into an agent's context or include it in a system prompt.

## Tracing

This CLI is instrumented with Langfuse tracing when credentials are configured (`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, and optional `LANGFUSE_HOST`).

It creates traces for each command and nested spans for API spec loading/help/execution, while only recording safe command metadata (not secret flags).

## API Reference

See the full [Langfuse API Reference](https://api.reference.langfuse.com/).
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
],
"type": "module",
"bin": {
"langfuse": "./bin/langfuse.mjs"
"langfuse": "./bin/langfuse.mjs",
"langfuse-cli": "./bin/langfuse.mjs"
},
"files": [
"bin",
Expand All @@ -34,6 +35,11 @@
"prepublishOnly": "rm -rf dist && bun run build"
},
"dependencies": {
"@langfuse/otel": "^5.3.0",
"@langfuse/tracing": "^5.3.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"specli": "^0.0.39"
},
"engines": {
Expand Down
199 changes: 178 additions & 21 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const LANGFUSE_FLAGS = new Set([
]);
const LANGFUSE_BOOL_FLAGS = new Set(["--refetch-api-spec"]);

type TracingRuntime = {
startActiveObservation: typeof import("@langfuse/tracing").startActiveObservation;
updateActiveObservation: typeof import("@langfuse/tracing").updateActiveObservation;
};

function loadEnvFile(filePath: string): void {
const content = readFileSync(filePath, "utf-8");
for (const line of content.split("\n")) {
Expand Down Expand Up @@ -64,6 +69,111 @@ async function getSpecText(params: {
return readFileSync(specPath, "utf-8");
}

function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}

async function runTracedStep<T>(params: {
tracing: TracingRuntime | null;
name: string;
input?: unknown;
fn: () => Promise<T>;
}): Promise<T> {
const { tracing, name, input, fn } = params;
if (!tracing) return fn();

return tracing.startActiveObservation(name, async () => {
const startedAt = Date.now();
if (input !== undefined) {
tracing.updateActiveObservation({ input });
}
try {
const result = await fn();
tracing.updateActiveObservation({
output: {
status: "ok",
durationMs: Date.now() - startedAt,
},
});
return result;
} catch (error) {
tracing.updateActiveObservation({
level: "ERROR",
statusMessage: getErrorMessage(error),
output: {
status: "error",
durationMs: Date.now() - startedAt,
},
});
throw error;
}
});
}

async function withLangfuseTracing<T>(params: {
publicKey: string | undefined;
secretKey: string | undefined;
host: string;
command: string;
fn: (tracing: TracingRuntime | null) => Promise<T>;
}): Promise<T> {
const { publicKey, secretKey, host, command, fn } = params;
if (!publicKey || !secretKey) {
return fn(null);
}

const [{ LangfuseSpanProcessor }, tracingModule, { NodeTracerProvider }] =
await Promise.all([
import("@langfuse/otel"),
import("@langfuse/tracing"),
import("@opentelemetry/sdk-trace-node"),
]);

const provider = new NodeTracerProvider({
spanProcessors: [
new LangfuseSpanProcessor({
publicKey,
secretKey,
baseUrl: host,
}),
],
});
provider.register();
tracingModule.setLangfuseTracerProvider(provider);

const tracing: TracingRuntime = {
startActiveObservation: tracingModule.startActiveObservation,
updateActiveObservation: tracingModule.updateActiveObservation,
};

try {
return await tracingModule.propagateAttributes(
{
traceName: `langfuse-cli.${command}`,
metadata: {
command,
runtime: "langfuse-cli",
},
tags: ["langfuse-cli", "cli"],
},
() =>
runTracedStep({
tracing,
name: "langfuse-cli.command",
input: { command },
fn: () => fn(tracing),
}),
);
} finally {
try {
await provider.shutdown();
} finally {
tracingModule.setLangfuseTracerProvider(null);
}
}
}

export async function run(argv: string[]): Promise<void> {
const extracted: Record<string, string> = {};
const boolFlags: Record<string, boolean> = {};
Expand Down Expand Up @@ -102,20 +212,36 @@ export async function run(argv: string[]): Promise<void> {

// First positional arg determines the subcommand
const subcommand = passthrough[2];
const commandForTracing = subcommand ?? "help";

if (subcommand === "api") {
passthrough.splice(2, 1);
return runApi({ passthrough, boolFlags, publicKey, secretKey, host });
}
await withLangfuseTracing({
publicKey,
secretKey,
host,
command: commandForTracing,
fn: async (tracing) => {
if (subcommand === "api") {
passthrough.splice(2, 1);
return runApi({
passthrough,
boolFlags,
publicKey,
secretKey,
host,
tracing,
});
}

if (subcommand === "get-skill") {
const skillPath = join(__dirname, "..", "skill", "langfuse-cli.md");
process.stdout.write(readFileSync(skillPath, "utf-8"));
return;
}
if (subcommand === "get-skill") {
const skillPath = join(__dirname, "..", "skill", "langfuse-cli.md");
process.stdout.write(readFileSync(skillPath, "utf-8"));
return;
}

// Show help for anything else (no args, --help, -h, unknown command)
printHelp();
// Show help for anything else (no args, --help, -h, unknown command)
printHelp();
},
});
}

function printHelp(): void {
Expand Down Expand Up @@ -196,12 +322,23 @@ async function runApi(params: {
publicKey: string | undefined;
secretKey: string | undefined;
host: string;
tracing: TracingRuntime | null;
}): Promise<void> {
const { passthrough, boolFlags, publicKey, secretKey, host } = params;
const { passthrough, boolFlags, publicKey, secretKey, host, tracing } = params;
const apiResource = passthrough[2] ?? "unknown-resource";
const apiAction = passthrough[3] ?? "help-action";

const specText = await getSpecText({
refetch: boolFlags["refetch-api-spec"] ?? false,
host,
const specText = await runTracedStep({
tracing,
name: "langfuse-cli.api.load-spec",
input: {
refetchApiSpec: boolFlags["refetch-api-spec"] ?? false,
},
fn: () =>
getSpecText({
refetch: boolFlags["refetch-api-spec"] ?? false,
host,
}),
});

// Intercept help: no args, --help, or -h
Expand All @@ -210,7 +347,17 @@ async function runApi(params: {
args.length === 0 ||
(args.length === 1 && (args[0] === "--help" || args[0] === "-h"))
) {
printApiHelp(await getResources(specText));
await runTracedStep({
tracing,
name: "langfuse-cli.api.help",
input: {
resource: apiResource,
action: apiAction,
},
fn: async () => {
printApiHelp(await getResources(specText));
},
});
return;
}

Expand All @@ -220,10 +367,20 @@ async function runApi(params: {
if (secretKey) inject.push("--password", secretKey);
specliArgv.splice(2, 0, ...inject);

const main = await loadMain();
await main(specliArgv, {
cliName: "langfuse api",
auth: "BasicAuth",
embeddedSpecText: specText,
await runTracedStep({
tracing,
name: "langfuse-cli.api.execute",
input: {
resource: apiResource,
action: apiAction,
},
fn: async () => {
const main = await loadMain();
await main(specliArgv, {
cliName: "langfuse api",
auth: "BasicAuth",
embeddedSpecText: specText,
});
},
});
}