trigger.dev
Version:
A Command-Line Interface for Trigger.dev projects
405 lines • 17.3 kB
JavaScript
import { confirm, intro, isCancel, log, multiselect, outro } from "@clack/prompts";
import chalk from "chalk";
import { Option as CommandOption } from "commander";
import { dirname, join } from "node:path";
import { readPackageJSON, resolvePackageJSON } from "pkg-types";
import * as semver from "semver";
import { z } from "zod";
import { OutroCommandError, wrapCommandAction } from "../cli/common.js";
import { BundledSkillsLoader, loadRulesManifest, } from "../rules/manifest.js";
import { sourceDir } from "../sourceDir.js";
import { cliLink } from "../utilities/cliOutput.js";
import { readConfigHasSeenRulesInstallPrompt, readConfigLastRulesInstallPromptVersion, writeConfigHasSeenRulesInstallPrompt, writeConfigLastRulesInstallPromptVersion, } from "../utilities/configFiles.js";
import { pathExists, readFile, safeWriteFile } from "../utilities/fileSystem.js";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
// Only tools with a native agent-skills directory. Rules-file-only tools (windsurf,
// gemini-cli, cline, amp, kilo, ruler) don't support the Agent Skills format yet, so
// they fall under the "Unsupported target" manual path rather than silently no-op.
const targets = ["claude-code", "cursor", "vscode", "agents.md"];
const targetLabels = {
"claude-code": "Claude Code",
cursor: "Cursor",
vscode: "VSCode (Copilot)",
"agents.md": "AGENTS.md (OpenAI Codex CLI, Jules, OpenCode)",
};
const SkillsCommandOptions = z.object({
target: z.enum(targets).array().optional(),
yes: z.boolean().optional(),
logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).optional(),
forceWizard: z.boolean().optional(),
});
export function configureSkillsCommand(program) {
return program
.command("skills")
.alias("install-rules")
.description("Install the Trigger.dev agent skills into your coding agent")
.option("--target <targets...>", "Choose the target (or targets) to install the Trigger.dev skills into. Native install is supported for: " +
targets.join(", "))
.option("-y, --yes", "Install all available skills for the selected targets without prompting")
.option("-l, --log-level <level>", "The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.", "log")
.addOption(new CommandOption("--force-wizard", "Force the skills install wizard to run even if the skills have already been installed.").hideHelp())
.action(async (options) => {
await printStandloneInitialBanner(true);
await installSkillsCommand(options);
});
}
export async function installSkillsCommand(options) {
return await wrapCommandAction("installSkillsCommand", SkillsCommandOptions, options, async (opts) => {
if (opts.logLevel) {
logger.loggerLevel = opts.logLevel;
}
return await _installSkillsCommand(opts);
});
}
/**
* Loads the agent skills bundled in this CLI (`<cli>/skills`, shipped via `files[]`).
* The skills dir and version are resolved from the CLI's own package.json, anchored at
* `sourceDir` (the CLI's location) rather than the user's cwd. The CLI is the only source
* of skills (there is no remote fallback), so this only returns null in the unexpected
* case that the CLI ships without any skills.
*
* tshy emits a dialect stub `package.json` ({"type":"module"}) in `dist/esm`, so the
* package.json nearest the bundled code is NOT the package root and has no `skills/`
* beside it. We walk up to the first package.json that has a `name` (the real root);
* that resolves correctly both when bundled (`<root>/dist/esm`) and from source
* (`<root>/src`, run via tsx in dev/tests).
*/
export async function resolveBundledPackageJSON(startDir = sourceDir) {
let searchDir = startDir;
for (let i = 0; i < 10; i++) {
const candidate = await resolvePackageJSON(searchDir);
const pkg = await readPackageJSON(candidate);
if (pkg.name) {
return candidate;
}
// Climb above this (stub) package.json and keep looking for the real root.
const above = dirname(dirname(candidate));
if (above === searchDir) {
return null;
}
searchDir = above;
}
return null;
}
async function loadSkillsManifest() {
try {
const packageJsonPath = await resolveBundledPackageJSON();
if (!packageJsonPath) {
return null;
}
const pkg = await readPackageJSON(packageJsonPath);
const skillsDir = join(dirname(packageJsonPath), "skills");
const version = typeof pkg.version === "string" ? pkg.version : "0.0.0";
return await loadRulesManifest(new BundledSkillsLoader(skillsDir, version));
}
catch {
return null;
}
}
async function _installSkillsCommand(options) {
if (options.forceWizard) {
await initiateSkillsInstallWizard(options);
return;
}
intro("Welcome to the Trigger.dev agent skills installer ");
const manifest = await loadSkillsManifest();
if (!manifest) {
log.warn("No Trigger.dev agent skills were found in this CLI build.");
outro("Nothing to install.");
return;
}
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
writeConfigHasSeenRulesInstallPrompt(true);
await installSkills(manifest, options);
outro("You're all set! ");
}
export async function initiateSkillsInstallWizard(options) {
const manifest = await loadSkillsManifest();
// The CLI couldn't load its own bundled skills (unexpected); nothing to offer.
if (!manifest) {
return;
}
const hasSeenPrompt = readConfigHasSeenRulesInstallPrompt();
if (!hasSeenPrompt) {
writeConfigHasSeenRulesInstallPrompt(true);
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
const installChoice = await confirm({
message: "Would you like to install the Trigger.dev agent skills?",
initialValue: true,
});
if (isCancel(installChoice) || !installChoice) {
return;
}
await installSkills(manifest, options);
return;
}
const lastVersion = readConfigLastRulesInstallPromptVersion();
if (!lastVersion) {
writeConfigHasSeenRulesInstallPrompt(true);
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
const installChoice = await confirm({
message: `A new version of the Trigger.dev agent skills is available (${manifest.currentVersion}). Do you want to install it?`,
initialValue: true,
});
if (isCancel(installChoice) || !installChoice) {
return;
}
await installSkills(manifest, options);
return;
}
if (semver.gt(manifest.currentVersion, lastVersion)) {
writeConfigHasSeenRulesInstallPrompt(true);
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
const confirmed = await confirm({
message: `A new version of the Trigger.dev agent skills is available (${lastVersion} → ${chalk.greenBright(manifest.currentVersion)}). Do you want to install it?`,
initialValue: true,
});
if (isCancel(confirmed) || !confirmed) {
return;
}
await installSkills(manifest, options);
}
}
/**
* Mark the agent-skills install prompt as already seen at the current skills version.
* `trigger init` calls this after offering skills in its AI-tooling step (whether or not
* the user installs them) so `trigger dev` doesn't ask about skills again. Returns false
* if the CLI ships without bundled skills.
*/
export async function markSkillsPromptSeen() {
const manifest = await loadSkillsManifest();
if (!manifest) {
return false;
}
writeConfigHasSeenRulesInstallPrompt(true);
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
return true;
}
/**
* Install skills as part of `trigger init`. The user already opted in via init's AI-tooling
* prompt, so this skips the extra confirm and goes straight to target/skill selection, then
* marks the prompt seen so `trigger dev` won't re-prompt. Returns false if the CLI ships
* without bundled skills.
*/
export async function installSkillsFromInit(opts = {}) {
const manifest = await loadSkillsManifest();
if (!manifest) {
return false;
}
writeConfigHasSeenRulesInstallPrompt(true);
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
// Returns true only if skills were actually written (false e.g. when the only target
// chosen is "unsupported"), so callers like `trigger init` don't claim skills are ready
// when nothing landed.
return await installSkills(manifest, opts);
}
async function installSkills(manifest, opts) {
const currentVersion = await manifest.getCurrentVersion();
const targetNames = await resolveTargets(opts);
if (targetNames.length === 1 && targetNames.includes("unsupported")) {
handleUnsupportedTargetOnly();
return false;
}
const results = [];
for (const targetName of targetNames) {
const result = await installSkillsForTarget(targetName, currentVersion, opts);
if (result) {
results.push(result);
}
}
const installedAny = results.some((r) => r.installations.length > 0 || r.pointer);
if (installedAny) {
log.step("Installed the following skills:");
for (const r of results) {
for (const installation of r.installations) {
log.info(chalk.greenBright(installation.location));
}
if (r.pointer) {
log.info(`${chalk.greenBright(r.pointer)} ${chalk.dim("(always-on pointer)")}`);
}
}
log.info(`${cliLink("Learn how to use Trigger.dev skills", "https://trigger.dev/docs/agents/rules/overview")}`);
}
return installedAny;
}
function handleUnsupportedTargetOnly() {
log.info(`${cliLink("Install the skills manually", "https://trigger.dev/docs/agents/rules/overview")}`);
}
async function installSkillsForTarget(targetName, currentVersion, opts) {
if (targetName === "unsupported") {
// This should not happen as unsupported targets are handled separately,
// but if it does, provide helpful output.
log.message(`${chalk.yellow("⚠")} Skipping unsupported target - see manual configuration above`);
return;
}
const options = await resolveOptionsForTarget(targetName, currentVersion, opts);
const installations = [];
for (const option of options) {
const installation = await performInstallSkillsOptionForTarget(targetName, option);
if (installation) {
installations.push(installation);
}
}
// Skills load on demand, so drop one always-on pointer into the tool's primary
// instructions file announcing what's installed (decision 7). Body lives in the
// on-demand skills; only this one-liner is always in context.
let pointer;
const skillsDir = resolveSkillsDirForTarget(targetName);
if (installations.length > 0 && skillsDir) {
pointer = await writeSkillsPointer(targetName, skillsDir, installations.map((i) => i.option.name));
}
return { targetName, installations, pointer };
}
/**
* Skills are whole folders (SKILL.md + optional references). We write the SKILL.md into
* the target tool's native skills directory under the skill's own folder so the tool
* discovers it. Targets without a native skills dir are skipped with a notice.
*/
async function performInstallSkillsOptionForTarget(targetName, option) {
const skillsDir = resolveSkillsDirForTarget(targetName);
if (!skillsDir) {
log.message(`${chalk.yellow("⚠")} ${targetLabels[targetName]} doesn't support agent skills yet, skipping "${option.name}".`);
return;
}
const location = join(skillsDir, option.name, "SKILL.md");
await safeWriteFile(join(process.cwd(), location), option.contents);
return { option, location };
}
function resolveSkillsDirForTarget(targetName) {
switch (targetName) {
case "claude-code": {
return ".claude/skills";
}
case "cursor": {
return ".cursor/skills";
}
case "vscode": {
return ".github/skills";
}
case "agents.md": {
return ".agents/skills";
}
default: {
return undefined;
}
}
}
const POINTER_START = "<!-- TRIGGER.DEV SKILLS START -->";
const POINTER_END = "<!-- TRIGGER.DEV SKILLS END -->";
/**
* The always-on instructions file for each skills-capable target. "region" files are
* shared (a marked block is upserted so we never clobber other content); "dedicated"
* files are ours to own and overwrite.
*/
function resolveSkillsPointerForTarget(targetName) {
switch (targetName) {
case "claude-code": {
return { file: "CLAUDE.md", mode: "region" };
}
case "cursor": {
return { file: ".cursor/rules/trigger-skills.mdc", mode: "dedicated" };
}
case "vscode": {
return { file: ".github/copilot-instructions.md", mode: "region" };
}
case "agents.md": {
return { file: "AGENTS.md", mode: "region" };
}
default: {
return undefined;
}
}
}
function buildSkillsPointerBody(skillsDir, skillNames) {
const list = skillNames.map((n) => `\`${n}\``).join(", ");
return [
"## Trigger.dev agent skills",
"",
`This project has Trigger.dev agent skills installed in \`${skillsDir}/\`. Before writing or changing Trigger.dev code (background tasks, scheduled tasks, realtime, or chat.agent AI agents), load the most relevant skill: ${list}.`,
].join("\n");
}
/**
* Writes/updates the one-line always-on pointer for a target. Idempotent: region files
* replace the marked block (or append it once); the dedicated Cursor rule is overwritten.
* Returns the written path, or undefined for targets without a pointer location.
*/
async function writeSkillsPointer(targetName, skillsDir, skillNames) {
const pointer = resolveSkillsPointerForTarget(targetName);
if (!pointer) {
return undefined;
}
const body = buildSkillsPointerBody(skillsDir, skillNames);
const absolutePath = join(process.cwd(), pointer.file);
if (pointer.mode === "dedicated") {
// Cursor: a dedicated always-apply rule file we own outright.
const contents = [
"---",
"description: Trigger.dev agent skills are installed in this repo",
"alwaysApply: true",
"---",
"",
body,
"",
].join("\n");
await safeWriteFile(absolutePath, contents);
return pointer.file;
}
const block = `${POINTER_START}\n${body}\n${POINTER_END}`;
if (!(await pathExists(absolutePath))) {
await safeWriteFile(absolutePath, `${block}\n`);
return pointer.file;
}
const existing = await readFile(absolutePath);
const pattern = new RegExp(`${POINTER_START}.*?${POINTER_END}`, "s");
const next = pattern.test(existing)
? existing.replace(pattern, block)
: `${existing.trimEnd()}\n\n${block}\n`;
await safeWriteFile(absolutePath, next);
return pointer.file;
}
async function resolveOptionsForTarget(targetName, currentVersion, opts) {
const possibleOptions = currentVersion.options.filter((option) => !option.client || option.client === targetName);
// Non-interactive: install everything available for this target.
if (opts.yes) {
return possibleOptions;
}
const selectedOptions = await multiselect({
message: `Choose the skills you want to install for ${targetLabels[targetName]}`,
options: possibleOptions.map((option) => ({
value: option,
label: option.title,
hint: `${option.label} [~${option.tokens} tokens]`,
})),
required: true,
});
if (isCancel(selectedOptions)) {
throw new OutroCommandError("No options selected");
}
return selectedOptions;
}
async function resolveTargets(options) {
if (options.target) {
return options.target;
}
const selectOptions = targets.map((target) => ({
value: target,
label: targetLabels[target],
}));
selectOptions.push({
value: "unsupported",
label: "Unsupported target",
hint: "We don't support this target yet, but you can still install the skills manually.",
});
const $selectOptions = selectOptions;
const selectedTargets = await multiselect({
message: "Select one or more targets to install the skills into",
options: $selectOptions,
required: true,
});
if (isCancel(selectedTargets)) {
throw new OutroCommandError("No targets selected");
}
return selectedTargets;
}
//# sourceMappingURL=skills.js.map