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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ Give your AI coding agents full visibility into your CI test results. The Curren

Agent skills that teach AI agents multi-step Currents workflows.

| Skill | Description |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| [`collect-evidence`](skills/collect-evidence) | Show that implemented work works, or demo it, using CI test artifacts — before/after screenshots, text attachments, videos, traces. |
| [`browser-evidence`](skills/browser-evidence) | Reproduce a bug in a browser you drive yourself, fix it, and post before/after evidence — for a change no CI test covers. |
| Skill | Description |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`browser-evidence`](skills/browser-evidence) | Prove a change works in a browser you drove yourself, when there is no test to run — reproduce a bug, fix it, and post before/after evidence on a pull request or issue. |
| [`collect-evidence`](skills/collect-evidence) | Show that work you implemented actually works, or demo it, using artifacts from tests running in CI via Currents — before/after screenshots, text and JSON attachments, videos, traces, and GIFs. |

The server publishes every skill as an MCP resource, so a connected agent can
list and read them without a local copy:
Expand Down
3 changes: 2 additions & 1 deletion mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"rm": "rm -rf dist",
"prepare": "git config core.hooksPath mcp-server/scripts/hooks",
"sync-readme": "node scripts/sync-readme-tools.mjs",
"sync-readme:check": "node scripts/sync-readme-tools.mjs --check",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "npm run build && vitest run",
Expand All @@ -46,7 +47,7 @@
"release:dry": "release-it --dry-run",
"format": "prettier --check src ../skills",
"format-fix": "prettier --write src ../skills",
"verify": "npm run format && tsc --noEmit && npm run build && vitest run"
"verify": "npm run format && npm run sync-readme:check && tsc --noEmit && npm run build && vitest run"
},
"author": "Currents Software Inc",
"homepage": "https://currents.dev",
Expand Down
12 changes: 12 additions & 0 deletions mcp-server/scripts/load-skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ export function loadSkills() {
`skills/${dirName}/SKILL.md declares name "${name}"; it must match the directory`,
);
}
// The name is put into a `skill://` URI and into a markdown link in the
// README, so it has to be safe in both. Letters, digits, `_` and `-` are,
// and encoding at the point of use would not be enough: `encodeURIComponent`
// leaves `(` and `)` alone and those close a markdown link destination
// early. It is also the class `host/readme.test.ts` reads a name back with,
// so a name outside it would be absent from the table it was just written
// to.
if (!/^[\w-]+$/.test(name)) {
throw new Error(
`skills/${dirName} must be named with letters, digits, "_" or "-"`,
);
}

return { name, description, files };
});
Expand Down
182 changes: 136 additions & 46 deletions mcp-server/scripts/sync-readme-tools.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
/**
* Regenerates the tools table in README.md from the actual registered tools.
* Regenerates the tools and skills tables in README.md from the source of
* truth for each: the registrations in `server.ts`, and the skill directories
* `loadSkills` reads.
*
* Both tables are generated because both are checked. `host/readme.test.ts`
* fails when either drifts from what the server carries, and a sync applies
* this script, so a tool or skill arriving from the monorepo updates its row
* here rather than needing one written by hand in this repository.
*
* Usage: node scripts/sync-readme-tools.mjs [--check]
* --check exit with code 1 if the README is out of date (useful in CI)
Expand All @@ -8,7 +15,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { register } from "node:module";
import { loadSkills } from "./load-skills.mjs";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const readmePath = join(root, "..", "README.md");
Expand Down Expand Up @@ -62,72 +69,155 @@ const serverSrc = readFileSync(join(root, "src", "server.ts"), "utf-8");
const toolRegex =
/(['"])(currents-[A-Za-z0-9_./-]+)\1\s*,\s*\{\s*description:\s*(['"])((?:\\.|(?!\3)[^\\])*)\3/g;

/**
* The lead sentence of a description, for a table cell. Both catalogs carry
* more than that in their descriptions — a tool's names its arguments, a
* skill's continues into the phrases that make an agent reach for it — and
* neither belongs in a table of contents.
*
* A break is `.`, `!` or `?`, then space, then a capital. The capital is what
* keeps "based on conditions like test title, file path, git branch, etc.
* Requires projectId" splitting where it should while leaving "e.g. the runId"
* and "1.5 times" alone — an abbreviation and a decimal continue in lower case
* or in a digit, where a new sentence does not. It is a rule about the shape of
* the text rather than a list of abbreviations, which the next abbreviation
* would not be on.
*
* @param {string} description
* @returns {string}
*/
function firstSentence(description) {
const lead = description.split(/(?<=[.!?])\s+(?=[A-Z])/)[0].trimEnd();
return /[.!?]$/.test(lead) ? lead : lead + ".";
}

/**
* A string literal's source text as the string it declares.
*
* The regex above captures what is between the quotes, so every escape in it
* is still two characters. Decoding the quotes alone left `\\` as a pair, which
* the markdown escape below then doubled again — a description declaring
* `C:\Users` reached the README as two backslashes.
*
* One pass rather than chained replaces, so a decoded backslash is not read
* again as the start of the next escape.
*
* @param {string} literal
* @returns {string}
*/
function decodeStringLiteral(literal) {
return literal.replace(/\\(.)/g, (_, char) =>
char === "n" ? "\n" : char === "t" ? "\t" : char,
);
}

let match;
while ((match = toolRegex.exec(serverSrc)) !== null) {
const name = match[2];
const description = match[4].replace(/\\(['"])/g, '$1');
const firstSentence = description.split(/\.\s/)[0];
const shortDesc = firstSentence.endsWith(".")
? firstSentence
: firstSentence + ".";
registeredTools.push({ name, shortDesc });
const description = decodeStringLiteral(match[4]);
registeredTools.push({ name, shortDesc: firstSentence(description) });
}

if (registeredTools.length === 0) {
console.error("ERROR: No tools found in server.ts — regex may need updating");
process.exit(1);
}

// ── Build the markdown table ────────────────────────────────────
const nameColWidth = Math.max(
"Tool".length,
...registeredTools.map((t) => `\`${t.name}\``.length)
);
const descColWidth = Math.max(
"Description".length,
...registeredTools.map((t) => t.shortDesc.length)
);

// ── Build the markdown tables ───────────────────────────────────
// Padded to the widest cell, which is what the tables in this README already
// look like. Nothing reformats them afterwards: `npm run format` covers `src`
// and `../skills`, not the README.
const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length));

const header = `| ${pad("Tool", nameColWidth)} | ${pad("Description", descColWidth)} |`;
const separator = `| ${"-".repeat(nameColWidth)} | ${"-".repeat(descColWidth)} |`;
const rows = registeredTools.map(
(t) =>
`| ${pad(`\`${t.name}\``, nameColWidth)} | ${pad(t.shortDesc, descColWidth)} |`
);
function markdownTable(headings, rows) {
// A `|` in a cell would close it early and add a column, which no test here
// would catch: `host/readme.test.ts` reads names out of the first cell and
// never looks at the shape of the row. Nothing in either catalog carries one
// today, and a description is free text that one day will.
//
// The backslash goes first, or escaping a description that already reads
// `a\|b` would write `a\\|b`, which is a literal backslash followed by a
// live delimiter — the corruption this is here to prevent.
//
// A run of whitespace becomes one space last: a row is one line, and a
// newline in a cell ends the row wherever it falls.
const cells = rows.map((row) =>
row.map((cell) =>
cell
.replaceAll("\\", "\\\\")
.replaceAll("|", "\\|")
.replace(/\s+/g, " ")
.trim(),
),
);
Comment thread
twk3 marked this conversation as resolved.
const widths = headings.map((heading, column) =>
Math.max(heading.length, ...cells.map((row) => row[column].length)),
);
const line = (row) =>
`| ${row.map((cell, column) => pad(cell, widths[column])).join(" | ")} |`;
return [
line(headings),
`| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`,
...cells.map(line),
].join("\n");
}

const table = [header, separator, ...rows].join("\n");
const toolsTable = markdownTable(
["Tool", "Description"],
registeredTools.map((tool) => [`\`${tool.name}\``, tool.shortDesc]),
);

// ── Splice the table into README.md ─────────────────────────────
const readme = readFileSync(readmePath, "utf-8");
const tableStart = readme.indexOf("| Tool");
// The name is the directory name — `load-skills.mjs` refuses a skill whose
// frontmatter disagrees with it — so the link cannot point at a directory that
// is not there. `host/readme.test.ts` reads the name back out of this link.
const skillsTable = markdownTable(
["Skill", "Description"],
loadSkills().map((skill) => [
`[\`${skill.name}\`](skills/${skill.name})`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
firstSentence(skill.description),
]),
);

if (tableStart === -1) {
const msg = 'Cannot find tools table anchor ("| Tool") in README.md';
if (checkOnly) {
console.error(`${msg} — cannot verify table freshness.`);
} else {
console.error(`${msg} — cannot update.`);
// ── Splice the tables into README.md ────────────────────────────
let readme = readFileSync(readmePath, "utf-8");
const stale = [];

for (const [heading, table, label] of [
["Tool", toolsTable, "tools"],
["Skill", skillsTable, "skills"],
]) {
// Anchored to the start of a line, so a description that happens to contain
// the heading cannot be mistaken for the table it belongs to.
const anchor = new RegExp(`^\\| ${heading}[ |]`, "m").exec(readme);
if (!anchor) {
console.error(
`Cannot find the ${label} table header ("| ${heading}") in README.md.`,
);
process.exit(1);
}
process.exit(1);
const start = anchor.index;
const blankLine = readme.indexOf("\n\n", start);
const end = blankLine === -1 ? readme.length : blankLine;
if (readme.slice(start, end) === table) {
continue;
}
stale.push(label);
readme = readme.slice(0, start) + table + readme.slice(end);
}

const tableEndMarker = readme.indexOf("\n\n", tableStart);
const tableEnd = tableEndMarker === -1 ? readme.length : tableEndMarker;

const oldTable = readme.slice(tableStart, tableEnd);

if (oldTable === table) {
console.log("README.md tools table is up to date.");
if (stale.length === 0) {
console.log("README.md tools and skills tables are up to date.");
process.exit(0);
}

if (checkOnly) {
console.error("README.md tools table is out of date. Run: npm run sync-readme");
console.error(
`README.md ${stale.join(" and ")} table out of date. Run: npm run sync-readme`,
);
process.exit(1);
Comment thread
twk3 marked this conversation as resolved.
}

const updated = readme.slice(0, tableStart) + table + readme.slice(tableEnd);
writeFileSync(readmePath, updated);
console.log(`README.md updated with ${registeredTools.length} tools.`);
writeFileSync(readmePath, readme);
console.log(
`README.md updated: ${registeredTools.length} tools, ${loadSkills().length} skills.`,
);
Loading