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
2 changes: 2 additions & 0 deletions .github/workflows/sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:

- name: Run sync
run: npm run sync
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit and push if changed
run: |
Expand Down
8 changes: 5 additions & 3 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
node_modules/

# Externally-authored skills synced from upstream repos.
# Keep their formatting verbatim — see skills.config.ts.
skills/checkly/
# Skills are either synced verbatim from upstream repos (see skills.config.ts)
# or authored as standalone docs — don't reformat either. Keeping the whole
# tree out also stops a daily sync of new upstream files from tripping the
# pre-commit format check.
skills/
15 changes: 8 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

Skills in this repo come in two flavours:

1. **Externally-authored** — the skill is authored in another Checkly repo and synced here on release. Listed in [`skills.config.ts`](skills.config.ts). **Do not edit these files in this repo** — your changes will be overwritten the next time the sync runs. Open a PR against the source repo instead.
1. **Externally-authored** — the skill is authored in another Checkly repo and mirrored here automatically. Listed in [`skills.config.ts`](skills.config.ts). **Do not edit these files in this repo** — the entire `skills/<name>/` directory is wiped and rewritten on every sync, so your changes will be overwritten. Open a PR against the source repo instead.
2. **Plugin-native** — the skill is authored here directly. Anything under `skills/<name>/` that is _not_ listed in `skills.config.ts` falls into this bucket.

For example, the `checkly` skill is externally-authored; it lives at [`checkly/checkly-cli`](https://github.com/checkly/checkly-cli/tree/main/skills/checkly).

> Heads up: in `checkly-cli`, the `skills/` directory at the repo root is itself generated at prepare time from `packages/cli/dist/ai-context/public-skills/`. The package source is the ultimate source of truth, but the synced `skills/<name>/` artifact on a release tag is what we pull from — that's what end users see and it's stable per release.
> Heads up: in `checkly-cli`, the `skills/` directory at the repo root is itself generated at prepare time from `packages/cli/dist/ai-context/public-skills/`. The package source is the ultimate source of truth, but the committed `skills/<name>/` directory is what we pull from — that's what end users see.

## Adding an externally-authored skill

Expand All @@ -22,10 +22,9 @@ For example, the `checkly` skill is externally-authored; it lives at [`checkly/c
path: "<path/to/skill/dir>",
ref: "<tag-branch-or-sha>",
},
files: ["SKILL.md", "README.md"],
}
```
`ref` is required — every source must be pinned explicitly. Prefer a release tag over a branch so syncs are reproducible.
The sync mirrors the **entire** `source.path` directory — every file under it, including nested directories like `references/`. There is no per-file list to maintain; files added or removed upstream flow through on the next sync. `ref` is required: point it at a branch (e.g. `main`) to track the latest, or a tag/SHA to pin a fixed version.
2. Run `npm run sync` to pull the files locally.
3. Commit `skills.config.ts` and `skills/<skill-name>/` together.

Expand All @@ -46,15 +45,17 @@ To add one:

## How syncing works

`scripts/sync.ts` reads `skills.config.ts`, fetches each declared file from the ref pinned in `source.ref` via `raw.githubusercontent.com`, and writes it into `skills/<name>/`. There is no transform — what's upstream lands here verbatim.
`scripts/sync.ts` reads `skills.config.ts` and, for each skill, lists every file under `source.path` at `source.ref` via the GitHub git-trees API, then fetches each file from `raw.githubusercontent.com`. It wipes and rewrites `skills/<name>/` so files removed upstream are removed here too. There is no transform — what's upstream lands here verbatim. If the git tree comes back truncated (a very large repo), the sync fails loudly rather than mirror an incomplete skill.

To bump a synced skill, change its `source.ref` in `skills.config.ts` and run:
Set `GITHUB_TOKEN` in the environment to authenticate the git-trees calls and avoid the low anonymous rate limit. CI passes the Actions token automatically; locally it is only needed if you hit the limit.

To change the ref a synced skill tracks, edit its `source.ref` in `skills.config.ts` and run:

```bash
npm run sync
```

The sync also runs in CI via `.github/workflows/sync.yml`, on a daily schedule plus manual `workflow_dispatch`. The workflow runs `npm run sync` against whatever refs are pinned in `skills.config.ts` and commits any changes directly to `main`. CI does not bump the pins — that's a manual edit to `skills.config.ts`.
The sync also runs in CI via `.github/workflows/sync.yml`, on a daily schedule plus manual `workflow_dispatch`. The workflow runs `npm run sync` against whatever refs are pinned in `skills.config.ts` and commits any changes directly to `main`. CI does not change the refs — that's a manual edit to `skills.config.ts`.

## Git hooks

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ Point Cursor at this repo to install the plugin.

## Skills shipped here

| Skill | Source |
| -------------------------- | ---------------------------------------------------------------------------------------------------------- |
| [checkly](skills/checkly/) | [checkly/checkly-cli](https://github.com/checkly/checkly-cli/tree/main/skills/checkly) — synced on release |
| Skill | Source |
| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| [checkly](skills/checkly/) | [checkly/checkly-cli](https://github.com/checkly/checkly-cli/tree/main/skills/checkly) — tracks `main`, synced daily |
| [playwright-best-practices-for-agents](skills/playwright-best-practices-for-agents/) | [checkly/docs](https://github.com/checkly/docs/tree/main/skills/playwright-best-practices-for-agents) — tracks `main`, synced daily |

## Adding more

Expand Down
89 changes: 83 additions & 6 deletions scripts/sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs } from "node:util";

Expand All @@ -18,21 +18,28 @@ export type Skill = {
/** Directory name under `skills/`. Must match the skill's frontmatter `name`. */
name: string;
source: Source;
/** Files to copy from `<source.repo>/<source.path>/` into `skills/<name>/`. */
files: [string, ...string[]];
};

export type Config = {
skills: Skill[];
};

type FetchLike = (url: string) => Promise<{
type FetchInit = { headers?: Record<string, string> };

type FetchLike = (
url: string,
init?: FetchInit,
) => Promise<{
ok: boolean;
status: number;
statusText: string;
text(): Promise<string>;
}>;

/** A single entry in a GitHub git-tree response. */
type TreeEntry = { path: string; type: "blob" | "tree" | "commit" };
type TreeResponse = { tree: TreeEntry[]; truncated: boolean };

export type WrittenEntry = {
skill: string;
file: string;
Expand Down Expand Up @@ -62,21 +69,90 @@ export function buildRawUrl({
return `https://raw.githubusercontent.com/${repo}/${ref}/${path}/${file}`;
}

function buildTreesUrl({ repo, ref }: { repo: string; ref: string }): string {
return `https://api.github.com/repos/${repo}/git/trees/${ref}?recursive=1`;
}

/**
* A skill name becomes a directory under `skills/` that gets wiped and
* rewritten. Reject anything that isn't a single path segment so a malformed
* config (empty, `.`, `..`, or a path separator) can't make `rm` reach outside
* `skills/`.
*/
function assertSafeSkillName(name: string): void {
if (name === "" || name === "." || name === ".." || /[/\\]/.test(name)) {
throw new Error(`Invalid skill name ${JSON.stringify(name)}: must be a single path segment.`);
}
}

/** Guards `writeFile` against a tree entry whose path escapes its skill dir. */
function assertInsideSkillDir(skillDir: string, dest: string): void {
const resolvedDir = resolve(skillDir);
const resolvedDest = resolve(dest);
if (resolvedDest !== resolvedDir && !resolvedDest.startsWith(resolvedDir + sep)) {
throw new Error(`Refusing to write ${dest}: resolves outside the skill directory ${skillDir}.`);
}
}

/**
* Lists every file under `source.path`, relative to that path, by reading the
* repo's git tree. Mirroring the whole directory keeps us in lockstep with
* upstream: files added or removed there flow through without touching config.
*/
async function listSkillFiles(
source: Source,
fetchImpl: FetchLike,
token?: string,
): Promise<string[]> {
const url = buildTreesUrl(source);
const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const res = await fetchImpl(url, { headers });
if (!res.ok) {
throw new Error(`Failed to list ${url}: ${res.status} ${res.statusText}`);
}

const body = JSON.parse(await res.text()) as TreeResponse;
// A truncated tree means GitHub capped the response and we'd silently miss
// files. Fail loudly rather than mirror an incomplete skill.
if (body.truncated) {
throw new Error(
`Tree for ${source.repo}@${source.ref} is truncated; cannot reliably mirror ${source.path}.`,
);
}

const prefix = `${source.path.replace(/\/+$/, "")}/`;
return body.tree
.filter((entry) => entry.type === "blob" && entry.path.startsWith(prefix))
.map((entry) => entry.path.slice(prefix.length));
}

export async function sync({
config,
root = REPO_ROOT,
fetchImpl = fetch as unknown as FetchLike,
token = process.env.GITHUB_TOKEN,
}: {
config: Config;
root?: string;
fetchImpl?: FetchLike;
token?: string;
}): Promise<WrittenEntry[]> {
const written: WrittenEntry[] = [];
for (const skill of config.skills) {
assertSafeSkillName(skill.name);
const files = await listSkillFiles(skill.source, fetchImpl, token);

const skillDir = join(root, "skills", skill.name);
// Wipe-and-rewrite so files deleted upstream don't linger locally. The
// skill directory is fully owned by sync, so this is safe.
await rm(skillDir, { recursive: true, force: true });
await mkdir(skillDir, { recursive: true });

for (const file of skill.files) {
for (const file of files) {
const url = buildRawUrl({
repo: skill.source.repo,
ref: skill.source.ref,
Expand All @@ -89,6 +165,7 @@ export async function sync({
}
const body = await res.text();
const dest = join(skillDir, file);
assertInsideSkillDir(skillDir, dest);
await mkdir(dirname(dest), { recursive: true });
await writeFile(dest, body);
written.push({ skill: skill.name, file, url, dest });
Expand Down
9 changes: 8 additions & 1 deletion skills.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ export const config: Config = {
path: "skills/checkly",
ref: "main",
},
files: ["SKILL.md", "README.md"],
},
{
name: "playwright-best-practices-for-agents",
source: {
repo: "checkly/docs",
path: "skills/playwright-best-practices-for-agents",
ref: "main",
},
},
],
};
Loading
Loading