diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index e20b014..3cd72b4 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 { 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"; @@ -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] [--project] [--no-agent-skills]", help: [ "Flags:", " --all do everything reasonable (assets + MCP snippets + health check)", @@ -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"), @@ -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; @@ -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"); diff --git a/src/installers/agent-skills.ts b/src/installers/agent-skills.ts new file mode 100644 index 0000000..7133d06 --- /dev/null +++ b/src/installers/agent-skills.ts @@ -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 = { + "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")); +} diff --git a/tests/agent-skills.test.ts b/tests/agent-skills.test.ts new file mode 100644 index 0000000..a04cce7 --- /dev/null +++ b/tests/agent-skills.test.ts @@ -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(); + } +});