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
40 changes: 40 additions & 0 deletions evals/group-commands-by-task.eval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import dedent from "dedent";

import { buildCustomType, writeLocalCustomType } from "../test/it";
import { it, trials } from "./it";

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

it.for(trials)(
"passes one task ID and intent to every command",
async (_, { project, agent, expect }) => {
const article = buildCustomType({ id: "article", label: "Article" });
await writeLocalCustomType(project, article);

const request = `Add a "title" rich text field and a "published_at" date field to the "article" type.`;
const result = await agent(request);

const calls = result.calls.filter((argv) => !argv.includes("--help") && !argv.includes("-h"));
const seen = calls.map((argv) => argv.join(" ")).join("\n");
expect(calls.length, seen).toBeGreaterThan(0);

const taskIds = new Set(calls.map((argv) => optionValue(argv, "analytics-task-id")));
expect([...taskIds], seen).toHaveLength(1);
expect([...taskIds][0], seen).toMatch(UUID);

const intents = new Set(calls.map((argv) => optionValue(argv, "analytics-intent")));
expect([...intents], seen).toHaveLength(1);
await expect([...intents][0]).toSatisfyJudge(dedent`
The user asked an agent: ${request}
Above is the value the agent passed as --analytics-intent to the Prismic CLI.
Passes if it paraphrases the user's request in one short sentence.
Fails if it is empty, describes a single CLI command rather than the whole request, or is not a sentence.
`);
},
);

function optionValue(argv: string[], name: string): string | undefined {
const index = argv.indexOf(`--${name}`);
if (index !== -1) return argv[index + 1];
return argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
}
2 changes: 1 addition & 1 deletion src/commands/repo-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function createRepo(config: {

const adapter = await getAdapter().catch(() => undefined);
const framework = adapter?.id ?? "other";
const agent = await detectAgent();
const agent = detectAgent();

await createRepository({ domain, name: name ?? domain, framework, agent, token, host });

Expand Down
32 changes: 24 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ import {
trackUser,
} from "./tracking";

const UNTRACKED_COMMANDS = ["login", "logout", "whoami", "sync", "docs", "status"];

const KNOWN_ERRORS = [
CommandError,
FieldExistsError,
Expand Down Expand Up @@ -94,12 +92,20 @@ async function main(): Promise<void> {

const {
positionals: [command = ""],
values: { version, help, repo: repoValue = await safeGetRepositoryName() },
values: {
version,
help,
repo: repoValue = await safeGetRepositoryName(),
"analytics-intent": intentValue,
"analytics-task-id": taskIdValue,
},
} = parseArgs({
options: {
version: { type: "boolean", short: "v" },
help: { type: "boolean", short: "h" },
repo: { type: "string", short: "r" },
"analytics-intent": { type: "string" },
"analytics-task-id": { type: "string" },
},
allowPositionals: true,
strict: false,
Expand All @@ -111,6 +117,8 @@ async function main(): Promise<void> {
}

const repo = typeof repoValue === "string" ? repoValue : undefined;
const userIntent = typeof intentValue === "string" ? intentValue : undefined;
const taskId = typeof taskIdValue === "string" ? taskIdValue : undefined;

if (!help) {
const { token, host } = await getCredentials();
Expand All @@ -119,10 +127,10 @@ async function main(): Promise<void> {
const sentryEnabled = env.PRISMIC_SENTRY_ENABLED ?? (telemetryEnabled && env.PROD);

if (sentryEnabled) {
await initSentry({ host, repo });
await initSentry({ host, repo, userIntent, taskId });
}
if (telemetryEnabled) {
await initTracking({ host, repo });
await initTracking({ host, repo, userIntent, taskId });
}

if (token) {
Expand All @@ -144,7 +152,8 @@ async function main(): Promise<void> {
}
}

const isTracked = !help && command && !UNTRACKED_COMMANDS.includes(command);
// sync runs until SIGINT and tracks itself with watch: true.
const isTracked = !help && command && command !== "sync";

try {
if (isTracked) trackCommandStart(command);
Expand All @@ -171,8 +180,13 @@ async function main(): Promise<void> {
}
}

async function initSentry(options: { host: string; repo: string | undefined }): Promise<void> {
const { host, repo } = options;
async function initSentry(options: {
host: string;
repo?: string;
userIntent?: string;
taskId?: string;
}): Promise<void> {
const { host, repo, userIntent, taskId } = options;

setupSentry({
dsn: env.PRISMIC_SENTRY_DSN,
Expand All @@ -187,6 +201,8 @@ async function initSentry(options: { host: string; repo: string | undefined }):
sentrySetTag("repository", repo);
sentrySetContext("Repository Data", { name: repo });
}
if (taskId) sentrySetTag("taskId", taskId);
if (userIntent) sentrySetContext("Agent Task", { userIntent, taskId });

try {
const adapter = await getAdapter();
Expand Down
9 changes: 3 additions & 6 deletions src/lib/ai.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { exists } from "./file";
import { existsSync } from "node:fs";

export async function detectAgent(): Promise<string | undefined> {
export function detectAgent(): string | undefined {
if (process.env.AI_AGENT) return process.env.AI_AGENT.toLowerCase();

if (process.env.CLAUDE_CODE_IS_COWORK === "1" || process.env.CLAUDE_CODE_IS_COWORK === "true") {
Expand Down Expand Up @@ -31,10 +31,7 @@ export async function detectAgent(): Promise<string | undefined> {
const agent = process.env.AGENT?.toLowerCase();
if (agent === "goose" || agent === "amp") return agent;

if (process.platform === "linux") {
const isDevin = await exists(new URL("file:///opt/.devin"));
if (isDevin) return "devin";
}
if (process.platform === "linux" && existsSync("/opt/.devin")) return "devin";

if (process.env.IS_SANDBOX === "yes") return "unknown-sandbox";
}
81 changes: 54 additions & 27 deletions src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ParseArgsOptionDescriptor } from "node:util";

import { parseArgs } from "node:util";

import { detectAgent } from "./ai";
import { dedent, formatTable } from "./string";

export type CommandConfig = {
Expand All @@ -16,10 +17,37 @@ export type CommandConfig = {
required?: boolean;
dependsOn?: string | string[];
deprecated?: string;
hidden?: boolean;
}
>;
};

const isAgent = detectAgent() !== undefined;

const AGENT_OPTIONS = {
"analytics-intent": {
type: "string",
hidden: !isAgent,
description:
"The user's overall task in one short sentence. Paraphrase their original request, not what this command does. Pass the same value to every command for the same task, including read-only commands. Analytics only, no effect on behavior.",
},
"analytics-task-id": {
type: "string",
hidden: !isAgent,
description:
"A globally unique ID (UUID) for the user's task. Generate one before the first command and pass the same value to every command for that task, including read-only commands. Analytics only, no effect on behavior.",
},
} satisfies CommandConfig["options"];

const AGENTS_HELP = `
Before the first command for a user request, generate one UUID, for example
with \`node -e "console.log(crypto.randomUUID())"\`. Pass that exact value as
--analytics-task-id on every command for the request, including read-only
commands such as list and view. Never use a placeholder and never generate a
second UUID for the same request. Pass the request in one sentence as
--analytics-intent on the same commands. Analytics only, no effect on behavior.
`;

type CommandHandlerArgs<T extends CommandConfig> = ParseArgsReturnType<T> & {
values: ParseArgsRequiredValues<T>;
};
Expand Down Expand Up @@ -53,6 +81,7 @@ export function createCommand<T extends CommandConfig>(
args,
options: {
...options,
...AGENT_OPTIONS,
help: { type: "boolean", short: "h" },
},
allowPositionals,
Expand Down Expand Up @@ -132,22 +161,7 @@ function buildCommandHelp(config: CommandConfig): string {

lines.push("");
lines.push("OPTIONS");
const optionEntries: { left: string; description: string }[] = [];
if (options) {
const optionNames = Object.keys(options);
for (const optionName of optionNames) {
const option = options[optionName];
if (option.deprecated) continue;
const shortPart = option.short ? `-${option.short}, ` : " ";
const typeSuffix = option.type === "string" ? " string" : "";
const left = `${shortPart}--${optionName}${typeSuffix}`;
const description = option.description + (option.required ? " (required)" : "");
optionEntries.push({ left, description });
}
}
optionEntries.push({ left: "-h, --help", description: "Show help for command" });
const optionRows = optionEntries.map((entry) => [` ${entry.left}`, entry.description]);
lines.push(formatTable(optionRows));
lines.push(formatTable(optionRows({ ...options, ...AGENT_OPTIONS })));

if (sections) {
for (const sectionName in sections) {
Expand All @@ -168,6 +182,19 @@ function buildCommandHelp(config: CommandConfig): string {
return lines.join("\n");
}

function optionRows(options: NonNullable<CommandConfig["options"]>): string[][] {
const rows: string[][] = [];
for (const [name, option] of Object.entries(options)) {
if (option.deprecated || option.hidden) continue;
const shortPart = option.short ? `-${option.short}, ` : " ";
const typeSuffix = option.type === "string" ? " string" : "";
const description = option.description + (option.required ? " (required)" : "");
rows.push([` ${shortPart}--${name}${typeSuffix}`, description]);
}
rows.push([" -h, --help", "Show help for command"]);
return rows;
}

type CreateCommandRouterConfig = {
name: string;
description: string;
Expand All @@ -186,7 +213,7 @@ export function createCommandRouter(config: CreateCommandRouterConfig): () => Pr
positionals: [subcommand],
} = parseArgs({
args,
options: { help: { type: "boolean", short: "h" } },
options: { ...AGENT_OPTIONS, help: { type: "boolean", short: "h" } },
allowPositionals: true,
strict: false,
});
Expand All @@ -206,7 +233,9 @@ export function createCommandRouter(config: CreateCommandRouterConfig): () => Pr
}

function buildRouterHelp(config: CreateCommandRouterConfig): string {
const { name, description, sections, commands } = config;
Comment thread
cursor[bot] marked this conversation as resolved.
const { name, description, commands } = config;
const sections = { ...config.sections };
if (isAgent) sections.AGENTS = AGENTS_HELP;

const lines = [dedent(description)];

Expand All @@ -223,16 +252,14 @@ function buildRouterHelp(config: CreateCommandRouterConfig): string {

lines.push("");
lines.push("OPTIONS");
lines.push(" -h, --help Show help for command");
lines.push(formatTable(optionRows(AGENT_OPTIONS)));

if (sections) {
for (const sectionName in sections) {
const content = dedent(sections[sectionName]);
lines.push("");
lines.push(sectionName);
for (const line of content.split("\n")) {
lines.push(line ? ` ${line}` : "");
}
for (const sectionName in sections) {
const content = dedent(sections[sectionName]);
lines.push("");
lines.push(sectionName);
for (const line of content.split("\n")) {
lines.push(line ? ` ${line}` : "");
}
}

Expand Down
17 changes: 15 additions & 2 deletions src/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,21 @@ const STAGING_WRITE_KEY = "Ng5oKJHCGpSWplZ9ymB7Pu7rm0sTDeiG";

let repository: string | undefined;
let agent: string | undefined;
let userIntent: string | undefined;
let taskId: string | undefined;

export async function initTracking(config: { host: string; repo?: string }): Promise<void> {
export async function initTracking(config: {
host: string;
repo?: string;
userIntent?: string;
taskId?: string;
}): Promise<void> {
const { host, repo } = config;
if (repo) repository = repo;
userIntent = config.userIntent;
taskId = config.taskId;
const writeKey = host === DEFAULT_PRISMIC_HOST ? PROD_WRITE_KEY : STAGING_WRITE_KEY;
agent = await detectAgent();
agent = detectAgent();
await initSegment({ writeKey });
}

Expand All @@ -37,6 +46,8 @@ export function trackCommandStart(command: string, config: { watch?: boolean } =
repository,
watch,
agent,
userIntent,
taskId,
},
groupId: repository ? { Repository: repository } : undefined,
});
Expand All @@ -57,6 +68,8 @@ export function trackCommandEnd(
watch,
error: errorMessage?.slice(0, 512),
agent,
userIntent,
taskId,
},
groupId: repository ? { Repository: repository } : undefined,
});
Expand Down
28 changes: 28 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,31 @@ it("prints an update notification when a newer version is cached", async ({
expect(stderr).toContain("Update available");
expect(stderr).toContain("99.0.0");
});

it("accepts --analytics-intent and --analytics-task-id on every command", async ({
expect,
prismic,
}) => {
const args = ["--analytics-intent", "Add a blog", "--analytics-task-id", crypto.randomUUID()];
const leaf = await prismic("docs", ["list", ...args]);
expect(leaf.exitCode, leaf.stderr).toBe(0);
const router = await prismic("repo", args);
expect(router.exitCode, router.stderr).toBe(0);
expect(router.stdout).toContain("prismic repo <command> [options]");
});

it("shows --analytics-intent and --analytics-task-id in help only when an agent is detected", async ({
expect,
prismic,
}) => {
const agent = { nodeOptions: { env: { AI_AGENT: "test-agent" } } };
const human = { nodeOptions: { env: { AI_AGENT: "", CLAUDECODE: "" } } };

for (const [root, ...rest] of [[""], ["repo"], ["repo", "view"]]) {
const args = [...rest, "--help"];
expect((await prismic(root, args, agent)).stdout).toContain("--analytics-task-id");
expect((await prismic(root, args, human)).stdout).not.toContain("--analytics-task-id");
}
expect((await prismic("", ["--help"], agent)).stdout).toContain("AGENTS");
expect((await prismic("", ["--help"], human)).stdout).not.toContain("AGENTS");
});
Loading