From e26322559efed5a35185635184910c138dc20dd2 Mon Sep 17 00:00:00 2001 From: Stefano Guerrini Date: Wed, 9 Sep 2026 15:54:52 +0200 Subject: [PATCH 1/2] feat: install skills where the agent reads them init wrote skills to .zenrows/skills/, a path no agent harness loads, so they shipped and were never seen. Measured in a container with no agent config: after init the agent picked a plain HTTP client in 8 of 8 runs, the same as an empty directory. Skills now also land in each named client's project skills directory, plus the vendor-neutral .agents/skills/. Project scope keeps them in the repo, so the team shares one behaviour and a reviewer sees them in the diff, rather than mutating directories in the user's home. A client is only listed once its path is verified: a wrong path fails silently, since the files land and the agent never reads them. --no-agent-skills opts out. See evals/agent-discovery for the measurement. --- src/cli/commands/init.ts | 16 ++++++- src/installers/agent-skills.ts | 76 ++++++++++++++++++++++++++++++++++ tests/agent-skills.test.ts | 59 ++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/installers/agent-skills.ts create mode 100644 tests/agent-skills.test.ts diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index e20b014..9fbd1ac 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -18,6 +18,7 @@ import { existsSync } from "node:fs"; import { createWorkspace, workspacePaths } from "../../core/workspace.ts"; import { installAsset, loadRegistry } from "../../core/registry.ts"; import { buildMcpConfig, MCP_CLIENTS } from "../../installers/mcp/index.ts"; +import { installProjectSkills, supportedSkillClients } from "../../installers/agent-skills.ts"; import { runFetch } from "../../adapters/protected-fetch.ts"; import { formatRequestCost } from "../../core/http.ts"; import { loadCapabilities } from "../../core/capabilities.ts"; @@ -30,7 +31,7 @@ const SMOKE_URL = "https://httpbin.io/html"; export const init: Command = { name: "init", summary: "Set up the workspace, auth, MCP, skills, and starter assets.", - usage: "zenrows init [--all] [--api-key ] [--agents a,b,c] [--browser] [--yes] [--no-test]", + usage: "zenrows init [--all] [--api-key ] [--agents a,b,c] [--browser] [--yes] [--no-test] [--no-agent-skills]", help: [ "Flags:", " --all do everything reasonable (assets + MCP snippets + health check)", @@ -40,6 +41,7 @@ export const init: Command = { " --browser allow browser escalation in policy", " --experimental allow experimental commands in policy", " --no-telemetry set telemetry=off", + " --no-agent-skills do not copy skills into the agent directories in this project", " --no-test skip the test Protected Fetch", " --yes non-interactive", ].join("\n"), @@ -60,6 +62,7 @@ export const init: Command = { experimental: { type: "boolean" }, "no-telemetry": { type: "boolean" }, "no-test": { type: "boolean" }, + "no-agent-skills": { type: "boolean" }, }); const all = values.all === true; @@ -113,6 +116,17 @@ export const init: Command = { if (want(values.mcp) || want(values.plugins) || values.agents) { section("MCP / agent configs"); const agents = (asString(values.agents) ?? "claude-code,cursor,vscode").split(",").map((s) => s.trim()).filter(Boolean); + + // Skills in .zenrows/ are invisible to every harness, so put a copy where + // the agent reads. Without this the agent never learns the CLI exists. + if (!values["no-agent-skills"]) { + for (const t of installProjectSkills(paths.root, agents)) { + log.success(`Installed ${t.skills.length} skill(s) into ${t.dir}/`); + } + const unsupported = agents.filter((a) => !supportedSkillClients().includes(a)); + if (unsupported.length) log.dim(`No verified skills path for: ${unsupported.join(", ")}. MCP config below covers them.`); + } + for (const a of agents) { try { const { client, snippet } = buildMcpConfig(a, "stdio"); diff --git a/src/installers/agent-skills.ts b/src/installers/agent-skills.ts new file mode 100644 index 0000000..1f4fee5 --- /dev/null +++ b/src/installers/agent-skills.ts @@ -0,0 +1,76 @@ +/** + * Project-scoped skill installation. + * + * `init` writes `.zenrows/skills/`, but no agent harness reads that path, so + * the skills ship and are never seen. This module copies them where the agent + * actually looks. + * + * Project scope, not the home directory: the skills land in the repo, so the + * whole team gets the same behaviour, a reviewer sees them in the diff, and + * nothing leaks into projects that have no relation to scraping. + */ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { loadRegistry, assetRunnable } from "../core/registry.ts"; +import { pkgPath } from "../core/paths.ts"; + +/** + * Where each client reads project-scoped skills. + * + * `.agents/skills` is the vendor-neutral location; the rest are client + * specific. Only add a client here once its path is verified, because a wrong + * path fails silently: the files land and the agent never reads them. + */ +export const CLIENT_SKILL_DIRS: Record = { + "claude-code": ".claude/skills", + cursor: ".cursor/skills", + generic: ".agents/skills", +}; + +export type SkillInstall = { client: string; dir: string; skills: string[] }; + +/** Skills usable against the current backend: available plus open beta. */ +function payload(): { name: string; path: string }[] { + return loadRegistry("skill") + .filter(assetRunnable) + .map((a) => ({ name: a.name, path: a.path })); +} + +/** + * Copy the skill payload into each client's project skills directory. + * Replaces our own skill directories and leaves every other one alone. + */ +export function installProjectSkills( + root: string, + clients: string[], + opts: { dryRun?: boolean } = {}, +): SkillInstall[] { + const skills = payload(); + const targets = [...new Set(["generic", ...clients])] + .map((client) => ({ client, rel: CLIENT_SKILL_DIRS[client] })) + .filter((t): t is { client: string; rel: string } => Boolean(t.rel)); + + return targets.map(({ client, rel }) => { + const dir = join(root, rel); + if (!opts.dryRun) { + mkdirSync(dir, { recursive: true }); + for (const s of skills) { + const dest = join(dir, s.name); + rmSync(dest, { recursive: true, force: true }); + cpSync(pkgPath(s.path), dest, { recursive: true }); + } + } + return { client, dir: rel, skills: skills.map((s) => s.name) }; + }); +} + +/** Clients we can install skills for, for error messages and help text. */ +export function supportedSkillClients(): string[] { + return Object.keys(CLIENT_SKILL_DIRS).filter((c) => c !== "generic"); +} + +/** True when a project already has our skills for a client. */ +export function hasProjectSkills(root: string, client: string): boolean { + const dir = CLIENT_SKILL_DIRS[client]; + return dir ? existsSync(join(root, dir, "zenrows")) : false; +} diff --git a/tests/agent-skills.test.ts b/tests/agent-skills.test.ts new file mode 100644 index 0000000..e872cea --- /dev/null +++ b/tests/agent-skills.test.ts @@ -0,0 +1,59 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { installProjectSkills, CLIENT_SKILL_DIRS } from "../src/installers/agent-skills.ts"; +import { tempRoot } from "./helpers.ts"; + +const CLAUDE_DIR = CLIENT_SKILL_DIRS["claude-code"] as string; + +test("installs skills where the agent reads them, not only in .zenrows", () => { + const { root, cleanup } = tempRoot(); + try { + const done = installProjectSkills(root, ["claude-code"]); + assert.ok(done.some((t) => t.client === "claude-code"), "claude-code targeted"); + assert.ok(done.some((t) => t.client === "generic"), "vendor-neutral path always written"); + assert.ok(existsSync(join(root, CLAUDE_DIR, "zenrows", "SKILL.md"))); + } finally { + cleanup(); + } +}); + +test("a rerun replaces our skills and leaves other skills alone", () => { + const { root, cleanup } = tempRoot(); + try { + const dir = join(root, CLAUDE_DIR); + mkdirSync(join(dir, "someone-elses"), { recursive: true }); + writeFileSync(join(dir, "someone-elses", "SKILL.md"), "keep me"); + + installProjectSkills(root, ["claude-code"]); + writeFileSync(join(dir, "zenrows", "SKILL.md"), "stale"); + installProjectSkills(root, ["claude-code"]); + + assert.equal(readFileSync(join(dir, "someone-elses", "SKILL.md"), "utf8"), "keep me"); + assert.notEqual(readFileSync(join(dir, "zenrows", "SKILL.md"), "utf8"), "stale"); + } finally { + cleanup(); + } +}); + +test("dryRun reports the targets without writing", () => { + const { root, cleanup } = tempRoot(); + try { + const done = installProjectSkills(root, ["claude-code"], { dryRun: true }); + assert.ok(done.length > 0); + assert.ok(!existsSync(join(root, CLAUDE_DIR))); + } finally { + cleanup(); + } +}); + +test("an unknown client is skipped, not written to a guessed path", () => { + const { root, cleanup } = tempRoot(); + try { + const done = installProjectSkills(root, ["not-a-real-agent"]); + assert.deepEqual(done.map((t) => t.client), ["generic"]); + } finally { + cleanup(); + } +}); From d3d2918cec23459ec2984452e354e8905f2e495b Mon Sep 17 00:00:00 2001 From: Stefano Guerrini Date: Wed, 9 Sep 2026 17:50:18 +0200 Subject: [PATCH 2/2] feat: install skills globally by default, --project to scope them to a repo A CLI installed once should work in every directory. Project scope meant an init per repository, and it wrote eight skill directories into the user's own codebase, where they show up in their next diff. Global is now the default and the output says so, naming the absolute path and the flag that changes it. --project keeps the copy inside the repository, which is the right choice when scraping is a dependency of that codebase and the team should share one behaviour. --- src/cli/commands/init.ts | 12 ++++++---- src/installers/agent-skills.ts | 43 ++++++++++++++++++++-------------- tests/agent-skills.test.ts | 20 ++++++++++------ 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 9fbd1ac..3cd72b4 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -18,7 +18,7 @@ import { existsSync } from "node:fs"; import { createWorkspace, workspacePaths } from "../../core/workspace.ts"; import { installAsset, loadRegistry } from "../../core/registry.ts"; import { buildMcpConfig, MCP_CLIENTS } from "../../installers/mcp/index.ts"; -import { installProjectSkills, supportedSkillClients } from "../../installers/agent-skills.ts"; +import { installAgentSkills, supportedSkillClients } from "../../installers/agent-skills.ts"; import { runFetch } from "../../adapters/protected-fetch.ts"; import { formatRequestCost } from "../../core/http.ts"; import { loadCapabilities } from "../../core/capabilities.ts"; @@ -31,7 +31,7 @@ const SMOKE_URL = "https://httpbin.io/html"; export const init: Command = { name: "init", summary: "Set up the workspace, auth, MCP, skills, and starter assets.", - usage: "zenrows init [--all] [--api-key ] [--agents a,b,c] [--browser] [--yes] [--no-test] [--no-agent-skills]", + usage: "zenrows init [--all] [--api-key ] [--agents a,b,c] [--browser] [--yes] [--no-test] [--project] [--no-agent-skills]", help: [ "Flags:", " --all do everything reasonable (assets + MCP snippets + health check)", @@ -41,7 +41,8 @@ export const init: Command = { " --browser allow browser escalation in policy", " --experimental allow experimental commands in policy", " --no-telemetry set telemetry=off", - " --no-agent-skills do not copy skills into the agent directories in this project", + " --project install skills into this repository instead of your home directory", + " --no-agent-skills do not install skills into the agent directories at all", " --no-test skip the test Protected Fetch", " --yes non-interactive", ].join("\n"), @@ -63,6 +64,7 @@ export const init: Command = { "no-telemetry": { type: "boolean" }, "no-test": { type: "boolean" }, "no-agent-skills": { type: "boolean" }, + project: { type: "boolean" }, }); const all = values.all === true; @@ -120,9 +122,11 @@ export const init: Command = { // Skills in .zenrows/ are invisible to every harness, so put a copy where // the agent reads. Without this the agent never learns the CLI exists. if (!values["no-agent-skills"]) { - for (const t of installProjectSkills(paths.root, agents)) { + const scope = values.project ? "project" : "global"; + for (const t of installAgentSkills(agents, { scope, root: paths.root })) { log.success(`Installed ${t.skills.length} skill(s) into ${t.dir}/`); } + if (scope === "global") log.dim("Installed for every project on this machine. Use --project to keep them in this repository."); const unsupported = agents.filter((a) => !supportedSkillClients().includes(a)); if (unsupported.length) log.dim(`No verified skills path for: ${unsupported.join(", ")}. MCP config below covers them.`); } diff --git a/src/installers/agent-skills.ts b/src/installers/agent-skills.ts index 1f4fee5..7133d06 100644 --- a/src/installers/agent-skills.ts +++ b/src/installers/agent-skills.ts @@ -1,21 +1,24 @@ /** - * Project-scoped skill installation. + * Skill installation into the directories agent harnesses actually read. * - * `init` writes `.zenrows/skills/`, but no agent harness reads that path, so - * the skills ship and are never seen. This module copies them where the agent - * actually looks. + * `init` writes `.zenrows/skills/`, but no harness reads that path, so the + * skills ship and are never seen. This module copies them where the agent + * looks. * - * Project scope, not the home directory: the skills land in the repo, so the - * whole team gets the same behaviour, a reviewer sees them in the diff, and - * nothing leaks into projects that have no relation to scraping. + * Global by default, because a CLI installed once should work in every + * directory rather than needing an `init` per repository. `--project` keeps the + * copy inside the repository instead, which is the right choice when scraping + * is a dependency of that codebase and the team should share one behaviour. */ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { loadRegistry, assetRunnable } from "../core/registry.ts"; import { pkgPath } from "../core/paths.ts"; /** - * Where each client reads project-scoped skills. + * Where each client reads skills, relative to the home directory or the + * repository root depending on scope. * * `.agents/skills` is the vendor-neutral location; the rest are client * specific. Only add a client here once its path is verified, because a wrong @@ -27,6 +30,7 @@ export const CLIENT_SKILL_DIRS: Record = { generic: ".agents/skills", }; +export type SkillScope = "global" | "project"; export type SkillInstall = { client: string; dir: string; skills: string[] }; /** Skills usable against the current backend: available plus open beta. */ @@ -37,21 +41,22 @@ function payload(): { name: string; path: string }[] { } /** - * Copy the skill payload into each client's project skills directory. + * Copy the skill payload into each client's skills directory. * Replaces our own skill directories and leaves every other one alone. */ -export function installProjectSkills( - root: string, +export function installAgentSkills( clients: string[], - opts: { dryRun?: boolean } = {}, + opts: { scope?: SkillScope; root?: string; dryRun?: boolean } = {}, ): SkillInstall[] { + const scope = opts.scope ?? "global"; + const base = scope === "global" ? homedir() : (opts.root ?? process.cwd()); const skills = payload(); const targets = [...new Set(["generic", ...clients])] .map((client) => ({ client, rel: CLIENT_SKILL_DIRS[client] })) .filter((t): t is { client: string; rel: string } => Boolean(t.rel)); return targets.map(({ client, rel }) => { - const dir = join(root, rel); + const dir = join(base, rel); if (!opts.dryRun) { mkdirSync(dir, { recursive: true }); for (const s of skills) { @@ -60,7 +65,7 @@ export function installProjectSkills( cpSync(pkgPath(s.path), dest, { recursive: true }); } } - return { client, dir: rel, skills: skills.map((s) => s.name) }; + return { client, dir, skills: skills.map((s) => s.name) }; }); } @@ -69,8 +74,10 @@ export function supportedSkillClients(): string[] { return Object.keys(CLIENT_SKILL_DIRS).filter((c) => c !== "generic"); } -/** True when a project already has our skills for a client. */ -export function hasProjectSkills(root: string, client: string): boolean { - const dir = CLIENT_SKILL_DIRS[client]; - return dir ? existsSync(join(root, dir, "zenrows")) : false; +/** True when our skills are already installed for a client at this scope. */ +export function hasAgentSkills(client: string, scope: SkillScope = "global", root?: string): boolean { + const rel = CLIENT_SKILL_DIRS[client]; + if (!rel) return false; + const base = scope === "global" ? homedir() : (root ?? process.cwd()); + return existsSync(join(base, rel, "zenrows")); } diff --git a/tests/agent-skills.test.ts b/tests/agent-skills.test.ts index e872cea..a04cce7 100644 --- a/tests/agent-skills.test.ts +++ b/tests/agent-skills.test.ts @@ -1,16 +1,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; -import { installProjectSkills, CLIENT_SKILL_DIRS } from "../src/installers/agent-skills.ts"; +import { installAgentSkills, CLIENT_SKILL_DIRS } from "../src/installers/agent-skills.ts"; import { tempRoot } from "./helpers.ts"; const CLAUDE_DIR = CLIENT_SKILL_DIRS["claude-code"] as string; -test("installs skills where the agent reads them, not only in .zenrows", () => { +test("project scope installs where the agent reads, not only in .zenrows", () => { const { root, cleanup } = tempRoot(); try { - const done = installProjectSkills(root, ["claude-code"]); + const done = installAgentSkills(["claude-code"], { scope: "project", root }); assert.ok(done.some((t) => t.client === "claude-code"), "claude-code targeted"); assert.ok(done.some((t) => t.client === "generic"), "vendor-neutral path always written"); assert.ok(existsSync(join(root, CLAUDE_DIR, "zenrows", "SKILL.md"))); @@ -19,6 +20,11 @@ test("installs skills where the agent reads them, not only in .zenrows", () => { } }); +test("global is the default scope and resolves under the home directory", () => { + const done = installAgentSkills(["claude-code"], { dryRun: true }); + for (const t of done) assert.ok(t.dir.startsWith(homedir()), `${t.dir} is under home`); +}); + test("a rerun replaces our skills and leaves other skills alone", () => { const { root, cleanup } = tempRoot(); try { @@ -26,9 +32,9 @@ test("a rerun replaces our skills and leaves other skills alone", () => { mkdirSync(join(dir, "someone-elses"), { recursive: true }); writeFileSync(join(dir, "someone-elses", "SKILL.md"), "keep me"); - installProjectSkills(root, ["claude-code"]); + installAgentSkills(["claude-code"], { scope: "project", root }); writeFileSync(join(dir, "zenrows", "SKILL.md"), "stale"); - installProjectSkills(root, ["claude-code"]); + installAgentSkills(["claude-code"], { scope: "project", root }); assert.equal(readFileSync(join(dir, "someone-elses", "SKILL.md"), "utf8"), "keep me"); assert.notEqual(readFileSync(join(dir, "zenrows", "SKILL.md"), "utf8"), "stale"); @@ -40,7 +46,7 @@ test("a rerun replaces our skills and leaves other skills alone", () => { test("dryRun reports the targets without writing", () => { const { root, cleanup } = tempRoot(); try { - const done = installProjectSkills(root, ["claude-code"], { dryRun: true }); + const done = installAgentSkills(["claude-code"], { scope: "project", root, dryRun: true }); assert.ok(done.length > 0); assert.ok(!existsSync(join(root, CLAUDE_DIR))); } finally { @@ -51,7 +57,7 @@ test("dryRun reports the targets without writing", () => { test("an unknown client is skipped, not written to a guessed path", () => { const { root, cleanup } = tempRoot(); try { - const done = installProjectSkills(root, ["not-a-real-agent"]); + const done = installAgentSkills(["not-a-real-agent"], { scope: "project", root }); assert.deepEqual(done.map((t) => t.client), ["generic"]); } finally { cleanup();