convex
Version:
Client for the Convex Cloud
221 lines (197 loc) • 6.38 kB
text/typescript
import child_process from "child_process";
import path from "path";
// eslint-disable-next-line no-restricted-imports
import { promises as fs } from "fs";
import { chalkStderr } from "chalk";
import { logMessage } from "../../../bundler/log.js";
import { getVersion, fetchAgentSkillsSha } from "../versionApi.js";
import { type AiFilesState } from "./state.js";
import { exhaustiveCheck } from "./utils.js";
import { type AiFilesProjectConfig } from "../config.js";
/**
* Resolve the configured agent list, falling back to defaults.
*/
function configuredSkillAgents(
aiFilesConfig?: AiFilesProjectConfig | undefined,
): string[] {
// We default to the two most popular agents for now, "codex" installs to `.agents` which also
// covers cursor and many other tools. See: https://github.com/vercel-labs/skills?tab=readme-ov-file#supported-agents
const defaultAgents = ["claude-code", "codex"];
return aiFilesConfig?.skills?.agents ?? defaultAgents;
}
/**
* Runs `npx skills add get-convex/agent-skills --yes` in the given directory.
* Returns true on success, false if the process fails or cannot be started.
*/
function runSkillsAdd(cwd: string, agents: string[]): Promise<boolean> {
const args = ["add", "get-convex/agent-skills", "--yes"];
for (const agent of agents) {
args.push("--agent", agent);
}
return runSkillsCommand(cwd, args).then(({ ok }) => ok);
}
/**
* Runs `npx skills remove <name...> --yes` to surgically remove only the
* Convex-managed skills, leaving any skills from other sources intact.
*/
function runSkillsRemove({
cwd,
skillNames,
}: {
cwd: string;
skillNames: string[];
}): Promise<boolean> {
return runSkillsCommand(cwd, ["remove", ...skillNames, "--yes"]).then(
({ ok }) => ok,
);
}
/**
* This function exists so we have a way to disable skills installs without pushing a new
* version of the convex CLI
*/
async function shouldRunSkillsCli(): Promise<boolean> {
const versionData = await getVersion();
if (versionData.kind === "error") return true;
if (versionData.kind === "ok") {
if (versionData.data.disableSkillsCli) {
const message =
versionData.data.disableSkillsCliMessage ??
"Agent skills are temporarily disabled.";
logMessage(chalkStderr.yellow(message));
return false;
}
return true;
}
return exhaustiveCheck(versionData);
}
/**
* Remove the skills-lock.json file if it only contains skills that we
* are removing. The `npx skills remove` command leaves the lockfile behind
* even when it's logically empty.
*/
async function removeSkillsLockIfEmpty({
projectDir,
removedSkillNames,
}: {
projectDir: string;
removedSkillNames: string[];
}): Promise<boolean> {
const lockPath = path.join(projectDir, "skills-lock.json");
try {
const content = await fs.readFile(lockPath, "utf8");
const lock = JSON.parse(content);
if (
!lock ||
typeof lock !== "object" ||
!lock.skills ||
typeof lock.skills !== "object"
) {
return false;
}
const remainingSkills = Object.keys(lock.skills).filter(
(name) => !removedSkillNames.includes(name),
);
if (remainingSkills.length === 0) {
await fs.unlink(lockPath);
return true;
}
return false;
} catch {
return false;
}
}
/**
* Install Convex agent skills and record the SHA into the state.
* Handles the kill-switch check and all logging internally.
*/
export async function installSkills({
projectDir,
state,
aiFilesConfig,
}: {
projectDir: string;
state: AiFilesState;
aiFilesConfig?: AiFilesProjectConfig | undefined;
}): Promise<void> {
const agents = configuredSkillAgents(aiFilesConfig);
if (agents.length === 0) return;
if (!(await shouldRunSkillsCli())) return;
logMessage("Installing Convex agent skills...");
const skillsOk = await runSkillsAdd(projectDir, agents);
if (!skillsOk) {
logMessage(
chalkStderr.yellow(
"Could not install agent skills. You can retry manually with: npx skills add get-convex/agent-skills",
),
);
return;
}
const sha = await fetchAgentSkillsSha();
if (sha) state.agentSkillsSha = sha;
logMessage(`${chalkStderr.green("✔")} Skills installed`);
}
export type RemoveInstalledSkillsStatus = "unchanged" | "removed" | "failed";
/**
* Remove Convex-managed agent skills and clean up the lock file if empty.
* Returns whether removal was skipped, succeeded, or failed.
*/
export async function removeInstalledSkills({
projectDir,
skillNames,
}: {
projectDir: string;
skillNames: string[];
}): Promise<RemoveInstalledSkillsStatus> {
if (skillNames.length === 0) return "unchanged";
if (!(await shouldRunSkillsCli())) return "unchanged";
logMessage(`Removing Convex agent skills: ${skillNames.join(", ")}`);
const skillsOk = await runSkillsRemove({ cwd: projectDir, skillNames });
if (!skillsOk) {
logMessage(
chalkStderr.yellow(
"Could not remove agent skills automatically. Remove them manually with: npx skills remove",
),
);
return "failed";
}
const lockRemoved = await removeSkillsLockIfEmpty({
projectDir,
removedSkillNames: skillNames,
});
if (lockRemoved)
logMessage(`${chalkStderr.green("✔")} Deleted skills-lock.json.`);
return "removed";
}
function runSkillsCommand(
cwd: string,
args: string[],
): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => {
const proc = child_process.spawn(
"npx",
["--yes", "skills@latest", ...args],
{
cwd,
stdio: "pipe",
// .cmd files on Windows require shell execution.
shell: process.platform === "win32",
},
);
let capturedOutput = "";
proc.stdout?.on("data", (chunk) => {
capturedOutput += chunk.toString();
});
proc.stderr?.on("data", (chunk) => {
capturedOutput += chunk.toString();
});
proc.on("close", (code) => {
if (code !== 0 && capturedOutput.trim().length > 0) {
const lines = capturedOutput.trim().split(/\r?\n/);
const tail = lines.slice(-10).join("\n");
logMessage(chalkStderr.gray(`skills output (tail):\n${tail}`));
}
resolve({ ok: code === 0, output: capturedOutput });
});
proc.on("error", () => resolve({ ok: false, output: capturedOutput }));
});
}