Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
49ff19e
feat(cli): add skills version check, update, and freshness manifest
WaterrrForever Jun 26, 2026
c6105f9
fix(cli): add execFile to child_process mock in skills test
WaterrrForever Jun 26, 2026
6961b21
feat(cli): init installs all skills; skills update pulls the full set
WaterrrForever Jun 26, 2026
05b5d55
feat(cli): skills check treats missing skills as needing an update
WaterrrForever Jun 26, 2026
8255774
fix(cli): install/update skills straight from the GitHub repo
WaterrrForever Jun 26, 2026
8e3435f
feat(cli): init checks skills against GitHub, installs only when stale
WaterrrForever Jun 26, 2026
1cf81c7
refactor(cli): address skills manifest review feedback
WaterrrForever Jun 26, 2026
497faf6
fix(cli): strict skills update + auto-discover any agent host
WaterrrForever Jun 26, 2026
5ac3a6c
fix(cli): resolve CodeQL file-system race + de-flake Windows npx test
WaterrrForever Jun 26, 2026
a743153
fix(cli): repair garbled npx smoke-test timeout comment
WaterrrForever Jun 26, 2026
1738a70
fix(cli): install skills once globally + symlink-mirror to every agent
WaterrrForever Jun 26, 2026
2aa3860
fix(cli): install skills with --full-depth so a fresh install reads a…
WaterrrForever Jun 26, 2026
a3f66cd
docs(skills): drop --skip-skills from workflow init so new projects r…
WaterrrForever Jun 26, 2026
3904420
Merge origin/main into feat/skills-version-check
WaterrrForever Jun 26, 2026
402fbfa
fix(cli): scope agent mirror to HyperFrames' own skills, not the whol…
WaterrrForever Jun 27, 2026
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
15 changes: 15 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@
// utils/compositionServer.ts; the remaining clone is per-command logging text
// (different labels/help lines) — extracting it would over-abstract.
"packages/cli/src/commands/present.ts",
// skillsManifest.test.ts: parallel arrange/act/assert cases for locateInstall
// (project vs global scope, per-agent host conventions, claude-code priority).
// Each case seeds a dir then asserts the resolved location/agent; collapsing
// the shared seed/assert shape would obscure what each scope/host verifies.
"packages/cli/src/utils/skillsManifest.test.ts",
// skills.test.ts: parallel prune cases (removed-in-global vs project, non-slug
// rejection, --source/--dir plumbing) share a mock-checkSkills → runSkillsUpdate
// → assert-remove-spawn shape; each verifies a distinct prune behavior, so
// extracting the shared scaffold would obscure what each case asserts.
"packages/cli/src/commands/skills.test.ts",
],
},
"health": {
Expand Down Expand Up @@ -298,6 +308,11 @@
// body is linear validation that reads clearly inline.
"packages/cli/src/commands/play.ts",
"packages/cli/src/commands/present.ts",
// sync-agent-dirs.ts: a build-time codegen that regex-parses upstream
// agents.ts. parseAgents/resolveGlobalExpr are branchy by nature (literal
// vs base-var args, validation throws) but small and well-tested via the
// generated table's shape test; this is dev tooling, not shipped runtime.
"packages/cli/scripts/sync-agent-dirs.ts",
],
},
}
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"build:beat-analyzer": "node scripts/build-beat-analyzer.mjs",
"build:copy": "node scripts/build-copy.mjs",
"typecheck": "tsc --noEmit",
"gen:skills-manifest": "tsx scripts/gen-skills-manifest.ts"
"gen:skills-manifest": "tsx scripts/gen-skills-manifest.ts",
"gen:agent-dirs": "tsx scripts/sync-agent-dirs.ts"
},
"dependencies": {
"@hono/node-server": "^1.8.0",
Expand Down
169 changes: 169 additions & 0 deletions packages/cli/scripts/sync-agent-dirs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Generate (or verify) packages/cli/src/utils/agentDirs.generated.ts — the
// home-relative GLOBAL skills directory for every agent the upstream
// vercel-labs/skills CLI knows about, plus a marker dir that means "this agent
// is installed on this machine".
//
// bun run --cwd packages/cli gen:agent-dirs # write/update (fetches upstream)
// bun run --cwd packages/cli gen:agent-dirs --check # verify only (CI / pre-commit)
// bun packages/cli/scripts/sync-agent-dirs.ts --src <path/to/agents.ts> # offline
//
// Why generated, not hand-maintained: `skills add --global` installs into the
// per-agent dirs encoded in upstream's `src/agents.ts` (~70 agents). We mirror
// the canonical store into those same dirs, so the list must track upstream. The
// `skills` npm package exports nothing importable (CLI-only, bundled dist), so
// we parse the source at a PINNED tag and commit the result — deterministic at
// runtime, no network at install time. Bump SKILLS_REF and re-run on upgrade.

import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

// Pin to the upstream release whose dir layout we install against. Bump this
// (and re-run) when the bundled `skills` version moves.
const SKILLS_REPO = "vercel-labs/skills";
const SKILLS_REF = "v1.5.13";
const AGENTS_TS_URL = `https://raw.githubusercontent.com/${SKILLS_REPO}/${SKILLS_REF}/src/agents.ts`;

const here = dirname(fileURLToPath(import.meta.url));
const outPath = join(here, "..", "src", "utils", "agentDirs.generated.ts");

const isCheck = process.argv.includes("--check");
const srcFlag = process.argv.indexOf("--src");
const srcPath = srcFlag !== -1 ? process.argv[srcFlag + 1] : undefined;

// The base directories agents.ts builds globalSkillsDir from. Each is an env
// override with a documented default — the mirror resolves them at runtime so a
// machine with XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR set lands in the
// same place the agent actually reads. We store the base NAME + suffix here
// rather than a frozen path so that resolution stays faithful to upstream.
const BASE_VARS = [
"home",
"configHome",
"codexHome",
"claudeHome",
"vibeHome",
"hermesHome",
"autohandHome",
] as const;
type BaseVar = (typeof BASE_VARS)[number];

interface AgentGlobalDir {
agent: string;
base: BaseVar;
sub: string;
}

function posixJoin(parts: string[]): string {
return parts
.flatMap((p) => p.split("/"))
.filter((s) => s && s !== ".")
.join("/");
}

/** Resolve a globalSkillsDir expression to { base, sub }, or null if none. */
function resolveGlobalExpr(expr: string): { base: BaseVar; sub: string } | null {
const e = expr.trim();
if (e === "undefined") return null; // agent defines no global skills dir
// openclaw's helper falls back to ~/.openclaw/skills when no variant is present.
if (e.startsWith("getOpenClawGlobalSkillsDir")) return { base: "home", sub: ".openclaw/skills" };
const m = e.match(/^join\(([\s\S]+)\)$/);
if (!m) throw new Error(`Unparseable globalSkillsDir: ${expr}`);
const args = m[1]!.split(",").map((a) => a.trim());
const first = args[0]!;
if (!(BASE_VARS as readonly string[]).includes(first)) {
throw new Error(`globalSkillsDir does not start with a known base var: ${expr}`);
}
const segs: string[] = [];
for (const raw of args.slice(1)) {
const lit = raw.match(/^['"]([^'"]*)['"]$/);
if (!lit) throw new Error(`Non-literal segment "${raw}" in globalSkillsDir: ${expr}`);
segs.push(lit[1]!);
}
return { base: first as BaseVar, sub: posixJoin(segs) };
}

function parseAgents(source: string): AgentGlobalDir[] {
const re = /^ {2}(?:"([a-z0-9-]+)"|'([a-z0-9-]+)'|([a-z0-9-]+)):\s*\{/gm;
const blocks: { key: string; pos: number }[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(source))) blocks.push({ key: m[1] || m[2] || m[3]!, pos: m.index });
if (blocks.length < 60) {
throw new Error(`Parsed only ${blocks.length} agent blocks — upstream layout likely changed`);
}

const out: AgentGlobalDir[] = [];
for (let i = 0; i < blocks.length; i++) {
const seg = source.slice(blocks[i]!.pos, blocks[i + 1] ? blocks[i + 1]!.pos : source.length);
const gd = seg.match(/globalSkillsDir:\s*([\s\S]+?),\n/);
const expr = gd ? gd[1]!.trim().replace(/\s+/g, " ") : "undefined";
const resolved = resolveGlobalExpr(expr);
if (resolved === null) continue; // no global dir (e.g. eve, promptscript)
out.push({ agent: blocks[i]!.key, base: resolved.base, sub: resolved.sub });
}
return out;
}

function render(rows: AgentGlobalDir[]): string {
const lines = rows.map(
(r) =>
` { agent: ${JSON.stringify(r.agent)}, base: ${JSON.stringify(r.base)}, sub: ${JSON.stringify(r.sub)} },`,
);
return `// @generated by packages/cli/scripts/sync-agent-dirs.ts — DO NOT EDIT.
// Source: ${SKILLS_REPO}@${SKILLS_REF} (src/agents.ts). Regenerate with:
// bun run --cwd packages/cli gen:agent-dirs
//
// Each entry is one agent the upstream \`skills\` CLI installs to. The agent's
// GLOBAL skills directory is \`join(<base>, <sub>)\`, where \`base\` is one of the
// env-overridable home dirs below (resolved at runtime by skillsMirror.ts, so
// XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR are honored). Agents with no
// global skills dir upstream (eve, promptscript) are omitted.

/** Env-overridable base dirs, matching upstream agents.ts. */
export type AgentDirBase =
${BASE_VARS.map((b) => ` | ${JSON.stringify(b)}`).join("\n")};

export interface AgentGlobalDir {
/** Upstream agent key. */
agent: string;
/** Base directory the global skills dir is rooted at. */
base: AgentDirBase;
/** POSIX suffix joined onto the resolved base. */
sub: string;
}

export const AGENT_GLOBAL_DIRS: readonly AgentGlobalDir[] = [
${lines.join("\n")}
];
`;
}

async function loadAgentsSource(): Promise<string> {
if (srcPath) return readFileSync(srcPath, "utf8");
const res = await fetch(AGENTS_TS_URL);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${AGENTS_TS_URL}`);
return res.text();
}

const source = await loadAgentsSource();
const rows = parseAgents(source);
const next = render(rows);

if (isCheck) {
let current = "";
try {
current = readFileSync(outPath, "utf8");
} catch {
/* missing → drift */
}
if (current !== next) {
console.error(
`agentDirs.generated.ts is out of date (parsed ${rows.length} agents from ${SKILLS_REPO}@${SKILLS_REF}).\n` +
`Run: bun run --cwd packages/cli gen:agent-dirs`,
);
process.exit(1);
}
console.log(`agentDirs.generated.ts is up to date (${rows.length} agents).`);
} else {
writeFileSync(outPath, next, "utf8");
console.log(`Wrote ${rows.length} agents → ${outPath}`);
}
22 changes: 11 additions & 11 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,12 +574,14 @@ async function scaffoldProject(
}

/**
* Ensure the project's AI coding skills are present and current. Checks the
* installed skills against the latest published on GitHub and only (re)installs
* when something is outdated or missing — so re-running `init` on an already
* up-to-date project is a no-op. Best-effort: if the version check can't reach
* GitHub, it installs anyway. The install itself (`installAllSkills`) pulls the
* full set straight from the GitHub repo.
* Ensure the AI coding skills are present and current. Checks the installed
* skills against the latest published on GitHub and only (re)installs when
* something is outdated or missing — so re-running `init` on an up-to-date
* machine is a no-op. Best-effort: if the version check can't reach GitHub, it
* installs anyway. The install itself (`installAllSkills`) installs the full set
* once GLOBALLY (~/.claude/skills + ~/.agents/skills) and mirrors it into every
* other installed agent, so it is project-independent — the check is global-first
* to match.
*/
async function ensureSkillsCurrent(destDir: string): Promise<void> {
const { installAllSkills } = await import("./skills.js");
Expand All @@ -596,11 +598,9 @@ async function ensureSkillsCurrent(destDir: string): Promise<void> {
}

if (needsInstall) {
// installAllSkills resolves the agent target set from destDir + the
// environment (Claude Code → claude-code; otherwise installed CLIs, else a
// Claude-Code + `.agents` floor). A freshly-scaffolded project has no agent
// folders yet, so this lands skills where the running agent will read them
// rather than spraying to every agent convention.
// installAllSkills installs the full set once globally and mirrors it into
// every installed agent's global dir — project-independent, so a freshly
// scaffolded project doesn't need any agent folders yet.
await installAllSkills({ cwd: destDir });
} else {
console.log(c.success("AI coding skills are already up to date."));
Expand Down
77 changes: 32 additions & 45 deletions packages/cli/src/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,31 @@ vi.mock("@clack/prompts", () => ({
// default returns nothing removed, and the prune test overrides per-call.
vi.mock("../utils/skillsManifest.js", () => ({
checkSkills: vi.fn(async () => ({ skills: [] })),
// installAllSkills resolves the HyperFrames skill names (lock-attributed) to
// scope the mirror; pin it so these arg-shape tests don't read a real lock.
hyperframesSkillNames: vi.fn(() => ["hyperframes"]),
}));

// Agent-target resolution probes the real cwd / PATH / env, which would make
// the spawned-args assertions environment-dependent. Pin it to a fixed result
// so these tests verify how the command BUILDS the spawn, not what's installed
// on the test host. The resolver's own decision tree is covered in
// skillsTargets.test.ts. buildSkillsAddArgs is reproduced (it's trivial) so the
// arg shape under test stays real.
vi.mock("../utils/skillsTargets.js", () => ({
resolveAgentTargets: vi.fn(() => ({ agents: ["claude-code", "universal"], reason: "test" })),
buildSkillsAddArgs: (agents: string[]) => ["--skill", "*", "--agent", ...agents, "--yes"],
// The install fans out to other agents via mirrorGlobalSkills, which touches
// the real $HOME. Stub it so these arg-shape tests never create symlinks in the
// dev machine's agent dirs — the mirror has its own isolated-HOME unit tests.
vi.mock("../utils/skillsMirror.js", () => ({
mirrorGlobalSkills: vi.fn(() => ({ source: null, mirrored: [] })),
}));

// The global install command this CLI runs (after `skills add <url>`).
const GLOBAL_ARGS = [
"--skill",
"*",
"--global",
"--agent",
"claude-code",
"universal",
"--copy",
"--full-depth",
"--yes",
] as const;

function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
value: platform,
Expand Down Expand Up @@ -101,7 +113,7 @@ describe("hyperframes skills", () => {
process.exitCode = prevExitCode;
});

it("sets GIT_CLONE_PROTECTION_ACTIVE=0 on the spawned skills CLI child (GH #316)", async () => {
it("sets clone-safe env on the spawned skills CLI child (GH #316 + LFS skip)", async () => {
setPlatform("linux");

const { default: skillsCmd } = await import("./skills.js");
Expand All @@ -113,42 +125,22 @@ describe("hyperframes skills", () => {
expect(first!.args).toContain("skills");
expect(first!.args).toContain("add");
expect(first!.env?.GIT_CLONE_PROTECTION_ACTIVE).toBe("0");
// --full-depth clones the repo; skip LFS so we don't drag in unrelated blobs.
expect(first!.env?.GIT_LFS_SKIP_SMUDGE).toBe("1");
});

it.each([
[
"linux",
"npx",
["--version"],
[
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
"--agent",
"claude-code",
"universal",
"--yes",
"--copy",
],
["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS],
],
[
"darwin",
"npx",
["--version"],
[
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
"--agent",
"claude-code",
"universal",
"--yes",
"--copy",
],
["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS],
],
[
"win32",
Expand All @@ -162,13 +154,7 @@ describe("hyperframes skills", () => {
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
"--agent",
"claude-code",
"universal",
"--yes",
"--copy",
...GLOBAL_ARGS,
],
],
] as const)(
Expand Down Expand Up @@ -201,11 +187,12 @@ describe("hyperframes skills", () => {
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
const args = state.spawnCalls[0]?.args ?? [];
// pulls the full set straight from GitHub
// pulls the full set straight from GitHub, globally, as a faithful clone
expect(args).toContain("https://github.com/heygen-com/hyperframes");
// every skill, but to a scoped agent set — never the `--all` (= `--agent '*'`) spray
expect(args).toContain("--skill");
expect(args).toContain("--agent");
expect(args).toContain("--global");
expect(args).toContain("--copy");
expect(args).toContain("--full-depth");
// never the `--all` (= `--agent '*'`) spray
expect(args).not.toContain("--all");
// `--agent` must be followed by a concrete key, never the `'*'` wildcard
const agentValue = args[args.indexOf("--agent") + 1];
Expand Down
Loading
Loading