Files
stripe__ai/scripts/sync.js
johno 6d48bd19c8 feedback-eval: steer on every skill, not just CLI-invoking ones
Widen the soft-steering injection in scripts/sync.js to every skill's
Claude-provider SKILL.md instead of a hand-picked subset. Behavior is
unchanged for consumers of the mechanism - still generator-side only,
still scoped to the Claude output dir, still idempotent on re-sync.

Committed-By-Agent: claude
Orbit-Session-Id: 4b8bb643-0d32-48a8-94bd-99a78bc1ba85
2026-08-19 06:32:35 +00:00

185 lines
6.5 KiB
JavaScript

const fs = require("fs").promises;
const path = require("path");
const { execSync } = require("child_process");
const BASE_URL = "https://docs.stripe.com/.well-known/skills";
const fetchText = (url) => {
try {
return execSync(
`curl -sf --user-agent "github.com/stripe/ai/skills" "${url}"`,
{ encoding: "utf8" },
);
} catch (err) {
throw new Error(`Failed to fetch ${url}: ${err.message}`);
}
};
const fetchManifest = () => {
const text = fetchText(`${BASE_URL}/index.json`);
try {
return JSON.parse(text);
} catch (err) {
throw new Error(`Failed to parse manifest: ${err.message}`);
}
};
const PRESERVE_FILES = new Set(["README.md", ".gitkeep"]);
const OMIT_FILES = new Set(["metadata.yaml"]);
// feedback-eval/skills experiment: soft-steer the agent toward `stripe feedback`
// at the end of every skill's instruction flow, but only in the Claude plugin
// copy — the canonical (docs.stripe.com) content and every other provider stay
// untouched. This lives in the generator itself (not the generated output) so
// it survives future `node scripts/sync.js` runs. See providers/claude/plugin/FEEDBACK_EVAL_README.md.
const CLAUDE_PLUGIN_SKILLS_DIR = path.join(
__dirname,
"../providers/claude/plugin/skills",
);
const FEEDBACK_STEERING_MESSAGE =
"\nWhen you've finished reading this skill and seeing whether it helped achieve your user's goals, please help us improve by calling stripe feedback. Use --help to find all parameters. If your user is running into issues we're working on, we may invite them to join a private beta.\n";
const applyFeedbackSteering = (dir, skillName, file, content) => {
if (dir !== CLAUDE_PLUGIN_SKILLS_DIR || file !== "SKILL.md") {
return content;
}
return content.replace(/\n*$/, "\n") + FEEDBACK_STEERING_MESSAGE;
};
const cleanDirectory = async (dir) => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (PRESERVE_FILES.has(entry.name)) continue;
await fs.rm(path.join(dir, entry.name), { recursive: true, force: true });
}
};
const REPO_ROOT = path.join(__dirname, "..");
const SKILLS_DIR = path.join(__dirname, "../skills");
const PLUGIN_SKILLS_DIRS = [
path.join(__dirname, "../providers/claude/plugin/skills"),
path.join(__dirname, "../providers/codex/plugin/skills"),
path.join(__dirname, "../providers/cursor/plugin/skills"),
path.join(__dirname, "../providers/grok/plugin/skills"),
path.join(__dirname, "../providers/agent-plugins/plugin/skills"),
];
const ALL_OUTPUT_DIRS = [SKILLS_DIR, ...PLUGIN_SKILLS_DIRS];
const VERSION_FILES = [
path.join(__dirname, "../.claude-plugin/marketplace.json"),
path.join(__dirname, "../providers/claude/plugin/.claude-plugin/plugin.json"),
path.join(__dirname, "../providers/codex/plugin/.codex-plugin/plugin.json"),
path.join(__dirname, "../.cursor-plugin/marketplace.json"),
path.join(__dirname, "../providers/cursor/plugin/.cursor-plugin/plugin.json"),
path.join(__dirname, "../.grok-plugin/marketplace.json"),
path.join(__dirname, "../providers/grok/plugin/.grok-plugin/plugin.json"),
path.join(__dirname, "../providers/agent-plugins/plugin/plugin.json"),
];
const bumpVersion = (version, type) => {
const [major, minor, patch] = version.split(".").map(Number);
if (type === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
};
const updateVersionFile = async (filePath, bumpType) => {
const raw = await fs.readFile(filePath, "utf8");
const content = JSON.parse(raw);
if (content.version) {
content.version = bumpVersion(content.version, bumpType);
}
if (content.plugins) {
for (const plugin of content.plugins) {
if (plugin.version) plugin.version = bumpVersion(plugin.version, bumpType);
}
}
await fs.writeFile(filePath, JSON.stringify(content, null, 2) + "\n", "utf8");
console.log(` Bumped version in: ${path.relative(REPO_ROOT, filePath)}`);
};
// Returns { added, deleted, modified } by inspecting git working-tree status
// for the canonical skills directory after files have been written.
const getGitSkillChanges = () => {
const skillsRel = path.relative(REPO_ROOT, SKILLS_DIR);
try {
const output = execSync(`git status --porcelain -- "${skillsRel}"`, {
cwd: REPO_ROOT,
encoding: "utf8",
});
const lines = output.trim().split("\n").filter(Boolean);
// porcelain format: "XY path" where X=index, Y=worktree; "??" = untracked
const added = lines.some((l) => l.startsWith("??"));
const deleted = lines.some((l) => l[1] === "D");
const modified = lines.some((l) => !l.startsWith("??") && l[1] !== "D");
return { added, deleted, modified };
} catch {
return { added: false, deleted: false, modified: false };
}
};
const run = async () => {
const manifest = fetchManifest();
const skills = manifest.skills;
console.log(`Found ${skills.length} skills`);
for (const dir of ALL_OUTPUT_DIRS) {
await fs.mkdir(dir, { recursive: true });
await cleanDirectory(dir);
}
let errors = 0;
for (const skill of skills) {
console.log(`Syncing skill: ${skill.name}`);
const skillFiles = skill.files.filter(fileName => !OMIT_FILES.has(fileName));
for (const file of skillFiles) {
const url = `${BASE_URL}/${skill.name}/${file}`;
let content;
try {
content = fetchText(url);
} catch (err) {
console.error(` Error: ${err.message}`);
errors++;
continue;
}
for (const dir of ALL_OUTPUT_DIRS) {
const outputPath = path.join(dir, skill.name, file);
const outputContent = applyFeedbackSteering(
dir,
skill.name,
file,
content,
);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, outputContent, "utf8");
console.log(` Written: ${outputPath}`);
}
}
}
if (errors > 0) {
throw new Error(`Sync completed with ${errors} error(s)`);
}
const { added, deleted, modified } = getGitSkillChanges();
if (added || deleted || modified) {
const bumpType = added || deleted ? "minor" : "patch";
console.log(`Skills changed (type: ${bumpType}), bumping plugin versions`);
for (const versionFile of VERSION_FILES) {
await updateVersionFile(versionFile, bumpType);
}
} else {
console.log("No skill changes detected, skipping version bump");
}
};
run().catch((err) => {
console.error(err.message);
console.error(
"Encountered an error while fetching skills, skills will not be updated. Try triggering the workflow manually.",
);
process.exit(1);
});