
Security News
The Code You Didn't Write Is Still Yours to Defend
AI agents are pulling packages into environments no scanner is watching, creating exposure before security teams can see it.
Scaffold and build Agent Skills in TypeScript — compile to a single zero-dependency ESM file
English | 中文
Skill engineering — author Agent Skills in TypeScript, compile to a single-file ESM, run with zero dependencies.
Output follows the agentskills.io specification and requires Node.js >= 18.
| Pain point | Solution |
|---|---|
| No reuse of common modules | skill-kits/runtime ships HTTP, routing, output, and error utilities you can import directly; the monorepo from init includes packages/shared, which esbuild inlines into the output |
| No standard template | pnpm new <name> generates a spec-aligned SKILL.md + project skeleton |
| JS lacks type hints, TS depends on tsc/bun | Author Skills in TS with full type hints; pnpm build uses esbuild to bundle into a single main.mjs in seconds — zero-dependency output that runs on Node alone on the Agent side |
| Lack of quality checks | pnpm lint validates SKILL.md for name/dir consistency, line count, reference validity, and description triggerability; build runs lint first by default |
npx skill-kits init my-skills # or in the current directory: npx skill-kits init
cd my-skills && pnpm install
init generates a pnpm monorepo:
my-skills/
├── pnpm-workspace.yaml
├── package.json
└── packages/
├── shared/ # shared business modules across Skills (@skills/shared)
└── skills/ # one sub-package per Skill, generated by new
pnpm new daily-report # add a Skill
pnpm dev daily-report --out ~/.agent/skills # watch + sync to the agent directory
pnpm build daily-report # build (auto lint + zip)
pnpm test daily-report # run unit tests
source output
┌──────────────────┐ ┌──────────────────────────┐
│ src/main.ts │ │ dist/ │
│ src/commands/ │ build │ ├── <skill-name>/ │
│ references/ │ ───────► │ │ ├── SKILL.md │
│ assets/ │ esbuild │ │ ├── scripts/ │
│ SKILL.md │ │ │ │ └── main.mjs │
└──────────────────┘ │ │ ├── references/ │
│ │ └── assets/ │
│ └── <skill-name>.zip │
└──────────────────────────┘
TypeScript + runtime import single-file ESM, zero deps
Agent: node scripts/main.mjs
Inside the workspace generated by
init, thepnpm new / dev / build / lintscripts are already wired up; the forms below are equivalent.
| Command | Purpose |
|---|---|
npx skill-kits init [dir] | Generate a Skill workspace skeleton (defaults to the current dir) |
pnpm new <name> | Add a Skill (with references/ assets/ placeholders) |
pnpm build [name] | Build → lint + pack into dist/<name>.zip by default |
pnpm dev <name> | Watch mode; src/ + SKILL.md + references/ + assets/ sync instantly |
pnpm lint [name] | Lint SKILL.md only |
pnpm test [name] | Run Skill unit tests (src/**/*.test.ts, based on node:test + tsx) |
pnpm pack <name> | Pack dist/<name>/ into dist/<name>.zip (build does this by default) |
| Command | Option | Description |
|---|---|---|
init | -n, --name <name> | Project name; defaults to the target directory name |
--locale <locale> | UI / template language: en | zh-CN (defaults to env detection) | |
new | --cwd <dir> | Workspace root directory (defaults to the current directory) |
--locale <locale> | Template language; defaults to the workspace config, then env | |
build | --minify | Minify the output JS |
--no-lint | Skip lint before building | |
--no-pack | Skip packing into a zip after building | |
dev | --out <dir> | Output to <dir>/<name>/, often used to sync directly to an agent skills dir |
--no-sourcemap | Disable sourcemap (inline by default) | |
--run [args] | After rebuild, auto-run node <bundle> [args] for quick local regression | |
lint | --strict | Treat warnings as errors |
test | --watch | Watch files and rerun tests automatically |
pack | --cwd <dir> | Workspace root directory (defaults to the current directory) |
| all | --cwd <dir> | Workspace root directory (defaults to the current directory) |
Import from skill-kits/runtime; everything is inlined into the output at build time.
createRouter automatically handles --help, required-argument validation, and error normalization:
import { createRouter, writeResult } from "skill-kits/runtime";
const router = createRouter({
name: "daily-report",
description: "...",
commonArgs: {
// common args automatically injected into every subcommand
domain: { type: "string", required: true, desc: "platform domain" },
token: { type: "string", required: true, desc: "SSO token" },
},
});
router.command({
name: "fetch",
description: "fetch yesterday's data",
args: {
date: { type: "string", required: true, desc: "YYYY-MM-DD" },
limit: { type: "number", default: 100, desc: "max items" },
tag: { type: "list", desc: "filter tags, repeatable" },
env: { type: "string", choices: ["boe", "online"] as const, desc: "environment" },
filter: { type: "json", desc: "complex filter, parsed from JSON" },
},
async handler({ date, limit, tag, env, filter, domain, token }) {
// env is typed as "boe" | "online" (inferred from choices); filter is already JSON.parse-d
writeResult({ ok: true, items: [] });
},
});
router.run(process.argv.slice(2));
args / commonArgs support 5 types: string / number / boolean / list / json. A missing required arg throws UserInputError; choices restricts the enum values and provides type inference; type: "json" auto-runs JSON.parse.
writeResult(payload) // stdout, single-line JSON for the Agent to consume
writeError(errorOrMessage, { code?, extra? }) // stderr + exitCode=1
notify(message) // stderr, progress/stage hints (ℹ️ prefix)
handleCliError(error) // top-level catch fallback, auto-detects SkillError subclasses
Zero dependencies, built on the global fetch (Node 18+). It never throws — network errors and non-2xx responses are both expressed via res.ok, and the caller decides how to handle them:
import { httpGet, httpPost } from "skill-kits/runtime";
const res = await httpGet<UserInfo>("https://api.example.com/me", {
headers: { authorization: `Bearer ${token}` },
query: { fields: "id,name" },
timeoutMs: 10_000,
});
if (!res.ok) throw new HttpError(res.status, url, res.statusText);
console.log(res.data?.name);
HttpRequestOptions: headers / query / body / signal / timeoutMs / redirect.
For PUT / DELETE and other methods, use httpRequest directly — see node_modules/skill-kits/dist/runtime/http.d.ts for the full parameter list.
Helpers for reading environment variables. requireEnv throws a unified USER_INPUT_ERROR (code=USER_INPUT_ERROR, details includes { env: "VAR_NAME" }) when missing, so the LLM can guide the user to configure it:
import { requireEnv, optionalEnv } from "skill-kits/runtime";
const token = requireEnv("OPENAPI_TOKEN", {
hint: "Apply at: https://example.com/get-token",
});
// unset → stderr: { "ok": false, "code": "USER_INPUT_ERROR", "error": "Missing environment variable OPENAPI_TOKEN. Apply at: ..." }
const pat = optionalEnv("FIGMA_PERSONAL_ACCESS_TOKEN"); // string | null
For long-polling scenarios (D2C code generation, SSO callbacks, etc.), use sleepWithHeartbeat instead of setTimeout to write progress to stderr every 5 seconds, preventing the Agent from mistakenly killing an idle process:
import { sleepWithHeartbeat } from "skill-kits/runtime";
await sleepWithHeartbeat(60_000, {
message: (rem) => `Waiting for code generation... ${rem}s left`,
});
// stderr every 5s: ℹ️ Waiting for code generation... 55s left
// stderr every 5s: ℹ️ Waiting for code generation... 50s left
// ...
SleepWithHeartbeatOptions: intervalMs / message (string or function).
All business errors extend SkillError; handleCliError automatically detects subclasses and outputs structured JSON to stderr. Built-in error codes let the LLM match an appropriate handling strategy:
| Class | code | Scenario |
|---|---|---|
UserInputError | USER_INPUT_ERROR | Missing argument / bad format |
AuthError | AUTH_ERROR | Token expired / insufficient perms |
HttpError | HTTP_ERROR | Upstream HTTP non-2xx |
BusinessApiError | BIZ_<code> | HTTP 200 but business code ≠ 0 |
import {
SkillError,
UserInputError,
BusinessApiError,
} from "skill-kits/runtime";
// UserInputError: argument validation failed
throw new UserInputError("activityId is required", { field: "activityId" });
// stderr → {"ok":false,"code":"USER_INPUT_ERROR","error":"activityId is required","details":{"field":"activityId"}}
// Custom BusinessApiError
throw new BusinessApiError(-10000, "token expired", {
hintMap: { [-10000]: "Please log in again", [-14]: "Record not found" },
});
// stderr → {"ok":false,"code":"BIZ_-10000","error":"[code=-10000] token expired (Please log in again)"}
// Custom business error: extend SkillError, name the code freely
class RateLimitError extends SkillError {
constructor(retryAfterSec?: number) {
super("RATE_LIMIT", "too many requests", { retryAfterSec });
}
}
throw new RateLimitError(30);
// stderr → {"ok":false,"code":"RATE_LIMIT","error":"too many requests","details":{"retryAfterSec":30}}
Skill unit tests follow the convention packages/skills/<name>/src/**/*.test.ts, run via pnpm test [name] (built on node:test + tsx, no extra config). The usual goal is to test a command's exit behavior — call the command function, then assert the stdout JSON and exitCode. skill-kits/testing provides two helpers for that; mockFetch is only needed when the command hits the network, while pure logic can be asserted directly:
import { test } from "node:test";
import assert from "node:assert/strict";
import { mockFetch, captureOutput } from "skill-kits/testing";
import { toSeconds } from "./utils.js";
import { createActivity } from "./create-activity.js";
const ctx = { domain: "https://example.com", token: "t" }; // resolved commonArgs
// 1) Pure function — no helper needed.
test("toSeconds normalizes ms to seconds", () => {
assert.equal(toSeconds(1717000000000), 1717000000);
});
// 2) Success path — captureOutput wraps the handler; mockFetch fakes HTTP.
test("create returns ok with backend data", async () => {
const mock = mockFetch([
{ match: /\/activity\/create/, json: { code: 0, data: { activity_id: 9001 } } },
]);
try {
const { json, exitCode } = await captureOutput(() =>
createActivity(ctx, { act_name: "test" }),
);
assert.equal(exitCode, 0);
assert.equal((json as { activity_id: number }).activity_id, 9001);
} finally {
mock.restore();
}
});
// 3) Error path — command functions throw SkillError (the router maps it to
// exit 1 + stderr JSON at the top level), so assert on the thrown error.
test("missing required arg throws", async () => {
await assert.rejects(
() => captureOutput(() => createActivity(ctx, {})),
"required",
);
});
captureOutput(fn) — captures what writeResult / writeError / notify write plus process.exitCode, returning { stdout, stderr, json, exitCode } (json is the parsed stdout). Restores everything even if fn throws. Use it for the success path; for the error path, command functions throw, so reach for assert.rejects.mockFetch(routes) — replaces the global fetch; match by substring / RegExp / function, reply with json / text / status / a custom response. An unmatched request throws, so missing mocks never pass silently. Call .restore() in a finally.mockFetch — pure functions just import and assert directly.runtime handles infrastructure reuse; domain-specific utilities/constants/clients live in packages/shared:
# add the dependency in the Skill sub-package's package.json
# "dependencies": { "@skills/shared": "workspace:*" }
pnpm install
// Export your helpers/constants/clients from packages/shared/src/index.ts,
// then import them in any Skill:
// import { yourHelper } from "@skills/shared";
At build time, esbuild inlines @skills/shared into the output, keeping it a zero-dependency single file. Leave the directory empty if you don't need it.
Put long docs in references/ and assets in assets/; the Agent references them on demand from SKILL.md to avoid polluting the context with a one-time full load. See the agentskills.io specification for reference conventions.
skill-kits is English-first; CLI output and generated templates follow the resolved locale (en or zh-CN). The locale is resolved in this order:
--locale <locale> flag (init / new)SKILL_KITS_LOCALE environment variablelocale field in the workspace .skillkitrc.json (used by new)LC_ALL / LANG environment variableseninit writes the resolved locale into .skillkitrc.json, and new inherits it so a workspace stays consistent. Templates can ship localized overrides named <base>.<locale>.<ext> (e.g. SKILL.zh-CN.md overrides SKILL.md); only the matching-locale variant is emitted, under the base name.
// .skillkitrc.json
{ "locale": "zh-CN" }
Place a .skillkitrc.json at the workspace root to customize lint behavior:
{
// "locale": "zh-CN", // see the Language section above
"lint": {
"triggerHints": ["何时", "trigger", "use when"],
"negativeHints": ["不要", "do not"],
"descriptionMinChars": 80,
"bodyLinesWarn": 400,
"bodyLinesFail": 500
}
}
| Rule | Level | Description |
|---|---|---|
name-matches-dir | error | name must equal the parent directory name |
body-line-limit | error | SKILL.md body > 500 lines |
body-line-soft | warn | SKILL.md body > 400 lines, consider splitting into references/ |
ref-relative | error | References must be relative paths |
ref-depth | error / warn | ../ raises error; > 1 directory level raises warn |
description-length | warn | description < 80 chars; too short risks under-triggering |
description-trigger | info | description lacks "when to trigger" hints |
description-negative | info | Consider adding "do not trigger" counterexamples |
frontmatter-unknown-key | warn | A frontmatter key not defined by the spec appeared |
MIT
FAQs
Scaffold and build Agent Skills in TypeScript — compile to a single zero-dependency ESM file
We found that skill-kits demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
AI agents are pulling packages into environments no scanner is watching, creating exposure before security teams can see it.

Security News
GitHub Actions checkout now blocks risky pull_request_target checkouts by default to help prevent pwn request supply chain attacks.

Product
Socket now supports Custom Roles and Repository Access Permissions so organizations can control who can access specific repositories and actions.