Skip to content
Open
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
20 changes: 19 additions & 1 deletion src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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";
Expand All @@ -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 <key>] [--agents a,b,c] [--browser] [--yes] [--no-test]",
usage: "zenrows init [--all] [--api-key <key>] [--agents a,b,c] [--browser] [--yes] [--no-test] [--project] [--no-agent-skills]",
help: [
"Flags:",
" --all do everything reasonable (assets + MCP snippets + health check)",
Expand All @@ -40,6 +41,8 @@ export const init: Command = {
" --browser allow browser escalation in policy",
" --experimental allow experimental commands in policy",
" --no-telemetry set telemetry=off",
" --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"),
Expand All @@ -60,6 +63,8 @@ export const init: Command = {
experimental: { type: "boolean" },
"no-telemetry": { type: "boolean" },
"no-test": { type: "boolean" },
"no-agent-skills": { type: "boolean" },
project: { type: "boolean" },
});

const all = values.all === true;
Expand Down Expand Up @@ -113,6 +118,19 @@ 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"]) {
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.`);
}

for (const a of agents) {
try {
const { client, snippet } = buildMcpConfig(a, "stdio");
Expand Down
83 changes: 83 additions & 0 deletions src/installers/agent-skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Skill installation into the directories agent harnesses actually read.
*
* `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.
*
* 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 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
* path fails silently: the files land and the agent never reads them.
*/
export const CLIENT_SKILL_DIRS: Record<string, string> = {
"claude-code": ".claude/skills",
cursor: ".cursor/skills",
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. */
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 skills directory.
* Replaces our own skill directories and leaves every other one alone.
*/
export function installAgentSkills(
clients: string[],
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(base, 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, 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 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"));
}
65 changes: 65 additions & 0 deletions tests/agent-skills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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 { 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("project scope installs where the agent reads, not only in .zenrows", () => {
const { root, cleanup } = tempRoot();
try {
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")));
} finally {
cleanup();
}
});

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 {
const dir = join(root, CLAUDE_DIR);
mkdirSync(join(dir, "someone-elses"), { recursive: true });
writeFileSync(join(dir, "someone-elses", "SKILL.md"), "keep me");

installAgentSkills(["claude-code"], { scope: "project", root });
writeFileSync(join(dir, "zenrows", "SKILL.md"), "stale");
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");
} finally {
cleanup();
}
});

test("dryRun reports the targets without writing", () => {
const { root, cleanup } = tempRoot();
try {
const done = installAgentSkills(["claude-code"], { scope: "project", root, 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 = installAgentSkills(["not-a-real-agent"], { scope: "project", root });
assert.deepEqual(done.map((t) => t.client), ["generic"]);
} finally {
cleanup();
}
});
Loading