Skip to content
Merged
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
16 changes: 16 additions & 0 deletions packages/braintrust-wizard/src/braintrust-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ export class BraintrustApiClient {
return { id, existed: headers.get("x-bt-found-existing") === "true" };
}

async getProject(id: string): Promise<Project> {
const { data } = await this.request<Project>(
"GET",
`/v1/project/${encodeURIComponent(id)}`,
);
return data;
}

async getOrg(id: string): Promise<Org> {
const { data } = await this.request<Org>(
"GET",
`/v1/organization/${encodeURIComponent(id)}`,
);
return data;
}

async listProjects(orgId: string): Promise<readonly Project[]> {
const { data } = await this.request<{ objects: Project[] }>(
"GET",
Expand Down
74 changes: 58 additions & 16 deletions packages/braintrust-wizard/src/clack-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { cwd as processCwd } from "node:process";

import pc from "picocolors";

import { WizardSessionAuthClient } from "./auth";
import {
WizardSessionAuthClient,
type WizardSessionCompleteResult,
} from "./auth";
import { BraintrustApiClient } from "./braintrust-api";
import { openBrowser } from "./browser";
import { buildLogsPermalink, buildCleanupMessage } from "./cleanup";
import { findGitRoot, isGitRepo, writeEnvBraintrust } from "./git";
Expand Down Expand Up @@ -107,12 +111,19 @@ export async function runClackWizard(deps: WizardDeps): Promise<WizardResult> {
prompts.log.warn(NOT_GIT_REPO_WARNING);
}

const session = await deps.authClient.login({
onLoginUrl: ({ loginUrl }) => {
prompts.note(wizardLoginPrompt({ loginUrl }), "Login");
},
onTryOpenBrowser: (url) => deps.openBrowser(url),
});
const session =
deps.options.apiKey !== undefined && deps.options.projectId !== undefined
? await loginWithCiCredentials({
apiKey: deps.options.apiKey,
projectId: deps.options.projectId,
apiUrl: deps.options.apiUrl,
})
: await deps.authClient.login({
onLoginUrl: ({ loginUrl }) => {
prompts.note(wizardLoginPrompt({ loginUrl }), "Login");
},
onTryOpenBrowser: (url) => deps.openBrowser(url),
});

prompts.log.success(
`Browser setup complete.\n org: ${pc.greenBright(session.orgName)}\n project: ${pc.greenBright(session.projectName)}`,
Expand All @@ -121,7 +132,15 @@ export async function runClackWizard(deps: WizardDeps): Promise<WizardResult> {
const provider = await selectProvider(deps);
let providerCredentials: Record<string, string> | undefined;
if (!provider.custom) {
providerCredentials = await collectCredentials(prompts, provider);
if (
deps.options.providerApiKey !== undefined &&
provider.envVar !== undefined &&
deps.options.provider?.id === provider.id
) {
providerCredentials = { [provider.envVar]: deps.options.providerApiKey };
} else {
providerCredentials = await collectCredentials(prompts, provider);
}
}

const gitRoot = await findGitRoot(deps.cwd);
Expand All @@ -146,13 +165,15 @@ export async function runClackWizard(deps: WizardDeps): Promise<WizardResult> {
let tracePermalink: string | undefined;
let resumeCommand: string | undefined;
if (canInstrument) {
const runIt = unwrap(
prompts,
await prompts.confirm({
initialValue: true,
message: RUN_HARNESS_QUESTION,
}),
);
const runIt = deps.options.instrument
? true
: unwrap(
prompts,
await prompts.confirm({
initialValue: true,
message: RUN_HARNESS_QUESTION,
}),
);
if (runIt) {
const result = await runInstrumentation(deps, {
org: session.orgName,
Expand Down Expand Up @@ -221,6 +242,9 @@ async function collectCredentials(
}

async function selectProvider(deps: WizardDeps): Promise<LlmProvider> {
if (deps.options.provider) {
return deps.options.provider;
}
const { prompts } = deps;
const value = unwrap(
prompts,
Expand Down Expand Up @@ -266,7 +290,8 @@ async function runInstrumentation(
const resultFilePath = allocateResultFile();
const promptText = renderPrompt({
languages: args.languages,
interactive: true,
interactive: !deps.options.yolo,
yolo: deps.options.yolo,
resultFilePath,
});
const harnessResult = await runHarness({
Expand Down Expand Up @@ -301,6 +326,23 @@ export type DefaultDepsArgs = {
readonly env?: NodeJS.ProcessEnv;
};

async function loginWithCiCredentials(args: {
readonly apiKey: string;
readonly projectId: string;
readonly apiUrl: string;
}): Promise<WizardSessionCompleteResult> {
const api = new BraintrustApiClient(args.apiUrl, args.apiKey);
const project = await api.getProject(args.projectId);
const org = await api.getOrg(project.org_id);
return {
apiKey: args.apiKey,
orgId: org.id,
orgName: org.name,
projectId: project.id,
projectName: project.name,
};
}

export function buildDefaultDeps(args: DefaultDepsArgs): WizardDeps {
const cwd = args.cwd ?? processCwd();
const env = args.env ?? process.env;
Expand Down
68 changes: 65 additions & 3 deletions packages/braintrust-wizard/src/options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import yargs from "yargs/yargs";

import { LLM_PROVIDERS, type LlmProvider } from "./providers";

export type WizardOptions = {
readonly apiUrl: string;
readonly appUrl: string;
readonly caCertPath: string | undefined;
readonly apiKey: string | undefined;
readonly projectId: string | undefined;
readonly instrument: boolean;
readonly yolo: boolean;
readonly provider: LlmProvider | undefined;
readonly providerApiKey: string | undefined;
};

const DEFAULT_API_URL = "https://api.braintrust.dev";
Expand All @@ -28,25 +36,79 @@ function buildParser(env: NodeJS.ProcessEnv) {
description: "Path to PEM CA bundle",
default: env["BRAINTRUST_CA_CERT"] ?? env["SSL_CERT_FILE"],
})
.epilog(
"Environment:\n CRANK_ENABLE_TELEMETRY=false Disable anonymous usage telemetry",
)
.help()
.alias("h", "help")
.strict();
}

function readEnvString(
env: NodeJS.ProcessEnv,
name: string,
): string | undefined {
const v = env[name];
return v && v.length > 0 ? v : undefined;
}

function readEnvBool(env: NodeJS.ProcessEnv, name: string): boolean {
const v = env[name];
if (!v) return false;
return ["1", "true", "yes", "on"].includes(v.toLowerCase());
}

export async function parseArgs(
argv: readonly string[],
env: NodeJS.ProcessEnv,
): Promise<WizardOptions> {
const parser = buildParser(env);
const parsed = await parser.parseAsync([...argv]);

const apiKey = readEnvString(env, "BRAINTRUST_SPARK_API_KEY");
const projectId = readEnvString(env, "BRAINTRUST_SPARK_PROJECT_ID");

if ((apiKey === undefined) !== (projectId === undefined)) {
throw new Error(
"BRAINTRUST_SPARK_API_KEY and BRAINTRUST_SPARK_PROJECT_ID must both be set together",
);
}

const yolo = readEnvBool(env, "BRAINTRUST_SPARK_YOLO");

const providerId = readEnvString(env, "BRAINTRUST_SPARK_PROVIDER");
const providerApiKey = readEnvString(
env,
"BRAINTRUST_SPARK_PROVIDER_API_KEY",
);

let provider: LlmProvider | undefined;
if (providerId !== undefined) {
const match = LLM_PROVIDERS.find((p) => p.id === providerId);
if (!match) {
const known = LLM_PROVIDERS.map((p) => p.id).join(", ");
throw new Error(
`Unknown BRAINTRUST_SPARK_PROVIDER "${providerId}". Known providers: ${known}`,
);
}
provider = match;
}

if (providerApiKey !== undefined) {
if (!provider) {
throw new Error(
"BRAINTRUST_SPARK_PROVIDER_API_KEY requires BRAINTRUST_SPARK_PROVIDER",
);
}
}

return {
apiUrl: stripTrailingSlash(parsed["api-url"] as string),
appUrl: stripTrailingSlash(parsed["app-url"] as string),
caCertPath: (parsed["ca-cert"] as string | undefined) || undefined,
apiKey,
projectId,
instrument: readEnvBool(env, "BRAINTRUST_SPARK_INSTRUMENT") || yolo,
yolo,
provider,
providerApiKey,
};
}

Expand Down
15 changes: 12 additions & 3 deletions packages/braintrust-wizard/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ In every other case, **stop and ask the user** before continuing. Do not guess,
export type RenderPromptOptions = {
readonly languages: readonly DetectedLanguage[];
readonly interactive: boolean;
readonly yolo?: boolean;
/**
* If set, a path the agent must write the trace permalink to (single line,
* just the URL) right before exiting. The wizard reads this file after the
Expand All @@ -124,9 +125,17 @@ export type RenderPromptOptions = {
};

export function renderPrompt(opts: RenderPromptOptions): string {
const runMode = opts.interactive
? "- **Interactive mode:** You can ask the user questions through the chat interface.\n"
: "- **Non-interactive mode:** You cannot ask the user questions. If a step requires user input (e.g., ambiguous language in a polyglot repo, unknown run command), abort with a clear explanation of what is needed.\n";
let runMode: string;
if (opts.yolo) {
runMode =
"- **Unattended mode (YOLO):** There is no user available to answer questions. Any instruction below that says to ask the user does not apply -- do not stop, do not wait, do not request input. Make the most reasonable choice from the evidence in the repo (pick the dominant language, use a conventional run command, etc.) and proceed. Only output `INSTRUMENTATION_INCOMPLETE` if instrumentation is genuinely impossible without input you cannot infer.\n";
} else if (opts.interactive) {
runMode =
"- **Interactive mode:** You can ask the user questions through the chat interface.\n";
} else {
runMode =
"- **Non-interactive mode:** You cannot ask the user questions. If a step requires user input (e.g., ambiguous language in a polyglot repo, unknown run command), abort with a clear explanation of what is needed.\n";
}

let languageContext: string;
let installSdkContext: string;
Expand Down
6 changes: 6 additions & 0 deletions packages/braintrust-wizard/test/clack-wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ function buildDeps(args: {
apiUrl: "https://api.test",
appUrl: "https://app.test",
caCertPath: undefined,
apiKey: undefined,
projectId: undefined,
instrument: false,
yolo: false,
provider: undefined,
providerApiKey: undefined,
},
prompts: args.prompts,
authClient: stubAuth,
Expand Down
Loading