convex
Version:
Client for the Convex Cloud
303 lines (270 loc) • 8.76 kB
text/typescript
import * as Sentry from "@sentry/node";
import path from "path";
import { Context } from "../../../bundler/context.js";
// eslint-disable-next-line no-restricted-imports
import { promises as fs } from "fs";
import { chalkStderr } from "chalk";
import { logMessage } from "../../../bundler/log.js";
import { promptYesNo } from "../utils/prompts.js";
import { fetchAgentSkillsCatalog } from "../versionApi.js";
import { type AiFilesPaths, aiDirForConvexDir } from "./paths.js";
import {
installGuidelinesFile,
hasGuidelinesInstalled,
} from "./guidelinesmd.js";
import {
attemptReadAiState,
readAiStateOrDefault,
writeAiState,
hasAiState,
} from "./state.js";
import { type AiFilesProjectConfig } from "../config.js";
import { exhaustiveCheck, isInInteractiveTerminal } from "./utils.js";
import {
hasAgentsMdInstalled,
applyAgentsMdSection,
attemptToRemoveAgentsMdSection,
} from "./agentsmd.js";
import {
hasClaudeMdInstalled,
applyClaudeMdSection,
attemptToRemoveClaudeMdSection,
} from "./claudemd.js";
import { installSkills, removeInstalledSkills } from "./skills.js";
import { removeLegacyCursorRulesFile as removeLegacyCursorRules } from "./cursorrules.js";
async function hasExistingAiFilesArtifacts({
projectDir,
convexDir,
}: AiFilesPaths): Promise<boolean> {
return (
(await hasGuidelinesInstalled(convexDir)) ||
(await hasAgentsMdInstalled(projectDir)) ||
(await hasClaudeMdInstalled(projectDir))
);
}
/**
* Install or refresh all Convex AI files.
*
* Reads the existing state if present, or starts from a blank one for a
* fresh install.
*/
export async function installAiFiles({
projectDir,
convexDir,
aiFilesConfig,
}: AiFilesPaths & {
aiFilesConfig?: AiFilesProjectConfig | undefined;
}): Promise<void> {
const convexDirName = path.relative(projectDir, convexDir);
const state = await readAiStateOrDefault(convexDir);
await installGuidelinesFile({ convexDir, state });
await applyAgentsMdSection({ projectDir, state, convexDirName });
await applyClaudeMdSection({ projectDir, state, convexDirName });
await installSkills({ projectDir, state, aiFilesConfig });
await removeLegacyCursorRules(projectDir);
await writeAiState({ state, convexDir });
}
async function attemptToInstallAiFiles(
opts: Parameters<typeof installAiFiles>[0],
): Promise<void> {
try {
await installAiFiles(opts);
} catch (error) {
Sentry.captureException(error);
}
}
type AiFilesStalenessStatus = "not-installed" | "stale" | "silent";
export function isAiFilesDisabled(
aiFilesConfig: AiFilesProjectConfig | undefined,
): boolean {
if (aiFilesConfig?.enabled !== undefined)
return aiFilesConfig.enabled === false;
return aiFilesConfig?.disableStalenessMessage === true;
}
async function determineAiFilesStaleness({
canonicalGuidelinesHash,
canonicalAgentSkillsSha,
aiFilesConfig,
projectDir,
convexDir,
}: {
canonicalGuidelinesHash: string | null;
canonicalAgentSkillsSha: string | null;
aiFilesConfig?: AiFilesProjectConfig | undefined;
} & AiFilesPaths): Promise<AiFilesStalenessStatus> {
if (isAiFilesDisabled(aiFilesConfig)) return "silent";
const result = await attemptReadAiState(convexDir);
if (result.kind === "no-file" || result.kind === "parse-error") {
const hasArtifacts = await hasExistingAiFilesArtifacts({
projectDir,
convexDir,
});
return hasArtifacts ? "silent" : "not-installed";
}
if (result.kind === "ok") {
const { state } = result;
if (canonicalGuidelinesHash === null && canonicalAgentSkillsSha === null)
return "silent";
const guidelinesStale =
canonicalGuidelinesHash !== null &&
state.guidelinesHash !== null &&
state.guidelinesHash !== canonicalGuidelinesHash;
const skillsStale =
canonicalAgentSkillsSha !== null &&
state.agentSkillsSha !== null &&
state.agentSkillsSha !== canonicalAgentSkillsSha;
return guidelinesStale || skillsStale ? "stale" : "silent";
}
return exhaustiveCheck(result);
}
/**
* Check whether the Convex AI files are out of date and log a nag message
* if so.
*/
export async function checkAiFilesStalenessAndLog(
opts: {
canonicalGuidelinesHash: string | null;
canonicalAgentSkillsSha: string | null;
aiFilesConfig?: AiFilesProjectConfig | undefined;
} & AiFilesPaths,
): Promise<void> {
const status = await determineAiFilesStaleness(opts);
if (status === "not-installed") {
logMessage(
chalkStderr.yellow(
`Convex AI files are not installed. Run ${chalkStderr.bold(`npx convex ai-files install`)} to get started or ${chalkStderr.bold(`npx convex ai-files disable`)} to hide this message.`,
),
);
return;
}
if (status === "stale") {
logMessage(
chalkStderr.yellow(
`Your Convex AI files are out of date. Run ${chalkStderr.bold(`npx convex ai-files update`)} to get the latest.`,
),
);
return;
}
if (status === "silent") return;
exhaustiveCheck(status);
}
/**
* Installs AI files and returns the aiFiles config to write.
*/
export async function enableAiFiles({
projectDir,
convexDir,
aiFilesConfig,
}: AiFilesPaths & {
aiFilesConfig?: AiFilesProjectConfig | undefined;
}): Promise<AiFilesProjectConfig> {
await installAiFiles({ projectDir, convexDir, aiFilesConfig });
// Deleting the deprecated disableStalenessMessage key
const { disableStalenessMessage: _, ...rest } = aiFilesConfig ?? {};
return { ...rest, enabled: true };
}
/**
* Returns the aiFiles config to write when disabling AI files.
*/
export function disableAiFiles(
aiFilesConfig?: AiFilesProjectConfig | undefined,
): AiFilesProjectConfig {
// Deleting the deprecated disableStalenessMessage key
const { disableStalenessMessage: _, ...rest } = aiFilesConfig ?? {};
return { ...rest, enabled: false };
}
export type RemoveAiFilesResult =
| { kind: "success" }
| { kind: "error"; message: string };
/**
* Remove all Convex AI files from the project.
* Called by `npx convex ai-files remove`.
*/
export async function removeAiFiles({
projectDir,
convexDir,
}: AiFilesPaths): Promise<RemoveAiFilesResult> {
const agentSkillsCatalog = await fetchAgentSkillsCatalog();
if (agentSkillsCatalog.kind === "error") {
return {
kind: "error",
message:
"Could not fetch canonical agent skills from version.convex.dev. Aborting `convex ai-files remove`.",
};
}
const removals = [
await attemptToRemoveAgentsMdSection(projectDir),
await attemptToRemoveClaudeMdSection(projectDir),
(await removeInstalledSkills({
projectDir,
skillNames: agentSkillsCatalog.data.skills.map(
({ skillName }) => skillName,
),
})) === "removed",
await removeLegacyCursorRules(projectDir),
await attemptToDeleteAiDir({ projectDir, convexDir }),
];
if (removals.some(Boolean)) logMessage("Convex AI files removed.");
else logMessage("No Convex AI files found — nothing to remove.");
return { kind: "success" };
}
async function attemptToDeleteAiDir({
projectDir,
convexDir,
}: AiFilesPaths): Promise<boolean> {
const aiDir = aiDirForConvexDir(convexDir);
const relPath = path.relative(projectDir, aiDir);
try {
await fs.rm(aiDir, { recursive: true });
logMessage(`${chalkStderr.green("✔")} Deleted ${relPath}/`);
return true;
} catch (error) {
if ((error as { code?: string }).code === "ENOENT") return false;
Sentry.captureException(error);
logMessage(
chalkStderr.yellow(`Could not delete ${relPath}/. Remove it manually.`),
);
return false;
}
}
async function hasAiFilesBeenInstalledBefore({
projectDir,
convexDir,
aiFilesConfig,
}: AiFilesPaths & {
aiFilesConfig?: AiFilesProjectConfig | undefined;
}): Promise<boolean> {
if (isAiFilesDisabled(aiFilesConfig)) return false;
return (
(await hasAiState(convexDir)) ||
(await hasExistingAiFilesArtifacts({ projectDir, convexDir }))
);
}
export async function attemptSetupAiFiles({
ctx,
convexDir,
projectDir,
aiFilesConfig,
}: {
ctx: Context;
aiFilesConfig?: AiFilesProjectConfig | undefined;
} & AiFilesPaths): Promise<void> {
if (!isInInteractiveTerminal()) return;
if (isAiFilesDisabled(aiFilesConfig)) return;
if (
await hasAiFilesBeenInstalledBefore({
projectDir,
convexDir,
aiFilesConfig,
})
) {
await attemptToInstallAiFiles({ projectDir, convexDir, aiFilesConfig });
return;
}
const shouldInstall = await promptYesNo(ctx, {
message: "Set up Convex AI files? (guidelines, AGENTS.md, agent skills)",
default: true,
});
if (shouldInstall)
await attemptToInstallAiFiles({ projectDir, convexDir, aiFilesConfig });
}