Skip to content
Merged
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
61 changes: 61 additions & 0 deletions skills/rig/samples/421-html-anchor-extractor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 421 - Html Anchor Extractor

```rig
import { agent, p, s, defineTool, repair } from "rig";
import { readFile } from "node:fs/promises";

const extractAnchors = defineTool("extractAnchors", {
description: "Extract all anchor href values from an HTML file and classify each link.",
parameters: s.object({ filePath: s.path }),
async handler({ filePath }: { filePath: string }) {
try {
const content = await readFile(filePath, "utf-8");
const re = /href=["']([^"']+)["']/gi;
const results: Array<{ url: string; type: "internal" | "external" | "fragment"; file: string }> = [];
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const url = m[1];
let type: "internal" | "external" | "fragment" = "internal";
if (url.startsWith("#")) type = "fragment";
else if (/^https?:\/\//.test(url) || url.startsWith("//")) type = "external";
results.push({ url, type, file: filePath });
}
return results;
} catch {
return [];
}
},
});

// Agent role: extract and classify all anchor links from HTML files in the workspace.
const htmlAnchorExtractor = agent({
model: "small",
instructions: p`Extract and classify all anchor href links from HTML files.

HTML files in workspace:
${p.bash("find . -name '*.html' -not -path '*/node_modules/*' | head -30")}

Steps:
1. For each file path, call extractAnchors to get links array.
2. Combine all links into a single links array.
3. totalLinks = links.length.
4. externalCount = links where type === "external".
5. internalCount = links where type === "internal".
6. fragmentCount = links where type === "fragment".`,
output: s.object({
links: s.array(s.object({
url: s.string,
type: s.enum("internal", "external", "fragment"),
file: s.string,
})),
totalLinks: s.int,
externalCount: s.int,
internalCount: s.int,
fragmentCount: s.int,
}),
tools: [extractAnchors],
addons: [repair()],
});

export default htmlAnchorExtractor;
```
51 changes: 51 additions & 0 deletions skills/rig/samples/422-ts-optional-chaining-counter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 422 - Ts Optional Chaining Counter

```rig
import { agent, p, s, defineTool, steering } from "rig";
import { readFile } from "node:fs/promises";

const countNullSafetyOperators = defineTool("countNullSafetyOperators", {
description: "Count optional chaining (?.) and nullish coalescing (??) operators in a TypeScript file.",
parameters: s.object({ filePath: s.path }),
async handler({ filePath }: { filePath: string }) {
try {
const content = await readFile(filePath, "utf-8");
const optionalChainingCount = (content.match(/\?\./g) ?? []).length;
const nullishCoalescingCount = (content.match(/\?\?(?!=)/g) ?? []).length;
return { optionalChainingCount, nullishCoalescingCount, total: optionalChainingCount + nullishCoalescingCount };
} catch {
return { optionalChainingCount: 0, nullishCoalescingCount: 0, total: 0 };
}
},
});

// Agent role: count optional chaining and nullish coalescing operators across TypeScript files.
const tsOptionalChainingCounter = agent({
model: "small",
instructions: p`Count optional chaining (?.) and nullish coalescing (??) operator usage in TypeScript files.

TypeScript files found:
${p.bash("find . -name '*.ts' -not -path '*/node_modules/*' -not -name '*.d.ts' | head -25")}

Steps:
1. For each file path, call countNullSafetyOperators to get per-file counts.
2. Build files record keyed by file path.
3. totalOptionalChaining = sum of all optionalChainingCount.
4. totalNullishCoalescing = sum of all nullishCoalescingCount.
5. mostUsedFile = path with highest total (omit if all totals are 0).`,
output: s.object({
files: s.record(s.object({
optionalChainingCount: s.int,
nullishCoalescingCount: s.int,
total: s.int,
})),
totalOptionalChaining: s.int,
totalNullishCoalescing: s.int,
mostUsedFile: s.optional(s.string),
}),
tools: [countNullSafetyOperators],
addons: [steering()],
});

export default tsOptionalChainingCounter;
```
54 changes: 54 additions & 0 deletions skills/rig/samples/423-git-file-size-tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 423 - Git File Size Tracker

```rig
import { agent, p, s, defineTool, repair } from "rig";
import { execSync } from "node:child_process";

const getFileSizeAtRevision = defineTool("getFileSizeAtRevision", {
description: "Get the byte size of a file at a specific git revision using git cat-file.",
parameters: s.object({ revision: s.string, filePath: s.string }),
handler({ revision, filePath }: { revision: string; filePath: string }) {
try {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] Shell injection risk: revision and filePath are interpolated directly into a shell command with execSync. If the agent passes paths containing spaces, quotes, or ;, the command will misbehave or execute unintended commands.

💡 Fix

Use spawnSync to pass arguments as an array, bypassing the shell entirely:

import { spawnSync } from "node:child_process";

const result = spawnSync("git", ["cat-file", "-s", `${revision}:${filePath}`], { encoding: "utf-8" });
const size = parseInt(result.stdout?.trim() ?? "0", 10);
return { size: isNaN(size) ? 0 : size };

const out = execSync(`git cat-file -s "${revision}:${filePath}" 2>/dev/null || echo 0`, { encoding: "utf-8" });
const size = parseInt(out.trim(), 10);
return { size: isNaN(size) ? 0 : size };
} catch {
return { size: 0 };
}
},
});

// Agent role: track file byte-size changes between HEAD~1 and HEAD.
const gitFileSizeTracker = agent({
model: "small",
instructions: p`Track file size changes between the previous commit (HEAD~1) and current HEAD.

Files changed in the last commit:
${p.bash("git diff --name-only HEAD~1 HEAD 2>/dev/null || echo ''")}

Steps:
1. For each file path, call getFileSizeAtRevision with "HEAD~1" then "HEAD".
2. delta = currentSize - previousSize.
3. change: "grew" if delta > 0, "shrank" if delta < 0, "unchanged" if 0.
4. Build files array with path, previousSize, currentSize, delta, change.
5. totalFiles = files.length.
6. largestGrowth = path with highest positive delta (omit if none grew).
7. largestShrink = path with most negative delta (omit if none shrank).`,
output: s.object({
files: s.array(s.object({
path: s.string,
previousSize: s.int,
currentSize: s.int,
delta: s.int,
change: s.enum("grew", "shrank", "unchanged"),
})),
totalFiles: s.int,
largestGrowth: s.optional(s.string),
largestShrink: s.optional(s.string),
}),
tools: [getFileSizeAtRevision],
addons: [repair()],
});

export default gitFileSizeTracker;
```
75 changes: 75 additions & 0 deletions skills/rig/samples/424-json-pretty-printer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 424 - Json Pretty Printer

```rig
import { agent, s, defineTool, repair } from "rig";
import { readFile, writeFile } from "node:fs/promises";

const readJsonFile = defineTool("readJsonFile", {
description: "Read and parse a JSON file.",
parameters: s.object({ filePath: s.path }),
async handler({ filePath }: { filePath: string }) {
const content = await readFile(filePath, "utf-8");
return JSON.parse(content) as unknown;
},
});

const analyzeJsonStructure = defineTool("analyzeJsonStructure", {
description: "Recursively count keys, depth, arrays, and objects in a JSON value.",
parameters: s.object({ json: s.unknown }),
handler({ json }: { json: unknown }) {
let keyCount = 0;
let depth = 0;
let arrayCount = 0;
let objectCount = 0;
function walk(val: unknown, d: number): void {
if (d > depth) depth = d;
if (Array.isArray(val)) {
arrayCount++;
for (const item of val) walk(item, d + 1);
} else if (val !== null && typeof val === "object") {
objectCount++;
for (const v of Object.values(val as Record<string, unknown>)) {
keyCount++;
walk(v, d + 1);
}
}
}
walk(json, 0);
return { keyCount, depth, arrayCount, objectCount };
},
});

const writeJsonFile = defineTool("writeJsonFile", {
description: "Write a value as pretty-printed JSON to a file.",
parameters: s.object({ filePath: s.path, content: s.unknown }),
async handler({ filePath, content }: { filePath: string; content: unknown }) {
await writeFile(filePath, JSON.stringify(content, null, 2), "utf-8");
return { written: true };
},
});

// Agent role: pretty-print a JSON file and report structural statistics.
const jsonPrettyPrinter = agent({
model: "small",
input: s.object({ inputFile: s.string, outputFile: s.string }),
instructions: `Pretty-print a JSON file and return structural statistics.

Steps:
1. Call readJsonFile with inputFile from input.
2. Call analyzeJsonStructure with the parsed JSON to get keyCount, depth, arrayCount, objectCount.
3. Call writeJsonFile with outputFile and the parsed JSON.
4. Return all stats, outputFile from input, and prettyPrinted: true.`,
output: s.object({
keyCount: s.int,
depth: s.int,
arrayCount: s.int,
objectCount: s.int,
outputFile: s.string,
prettyPrinted: s.boolean,
}),
tools: [readJsonFile, analyzeJsonStructure, writeJsonFile],
addons: [repair()],
});

export default jsonPrettyPrinter;
```
55 changes: 55 additions & 0 deletions skills/rig/samples/425-ts-interface-method-counter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 425 - Ts Interface Method Counter

```rig
import { agent, p, s, defineTool, steering } from "rig";
import { readFile } from "node:fs/promises";

const countInterfaceMethods = defineTool("countInterfaceMethods", {
description: "Count method signatures inside TypeScript interface declarations in a file.",
parameters: s.object({ filePath: s.path }),
async handler({ filePath }: { filePath: string }) {
const content = await readFile(filePath, "utf-8");
const results: Record<string, { methodCount: number; hasOptionalMethods: boolean; sourceFile: string }> = {};
const ifaceRe = /interface\s+(\w+)[^{]*\{([^}]*)\}/gs;
let m: RegExpExecArray | null;
while ((m = ifaceRe.exec(content)) !== null) {
const name = m[1];
const body = m[2];
const methods = (body.match(/\w+\??\s*\([^)]*\)/g) ?? []);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The regex /interface\s+(\w+)[^\{]*\{([^}]*)\}/gs won't match interfaces whose bodies contain nested braces (e.g. { key: Record<string, { a: string }> }). The greedy [^}]* stops at the first }, producing an empty or partial body.

💡 Fix

For a sample, acknowledging the limitation in a comment is fine. If correctness matters, consider a brace-counting approach:

// Simple depth-aware body extraction
function extractInterfaceBody(src: string, start: number): string {
  let depth = 0, i = start;
  while (i < src.length) {
    if (src[i] === '{') { depth++; }
    else if (src[i] === '}') { if (--depth === 0) return src.slice(start + 1, i); }
    i++;
  }
  return "";
}

Or note the limitation with a comment so readers understand the scope.

const hasOptionalMethods = /\w+\?\s*\(/.test(body);
results[name] = { methodCount: methods.length, hasOptionalMethods, sourceFile: filePath };
}
return results;
},
});

// Agent role: count method signatures in TypeScript interfaces across source files.
const tsInterfaceMethodCounter = agent({
model: "small",
instructions: p`Count method signatures in TypeScript interface declarations.

TypeScript source files:
${p.glob("src/**/*.ts")}

For each file path, call countInterfaceMethods and merge the returned records.
Compute:
- totalInterfaces = total interface names found
- averageMethodCount = total methods / totalInterfaces (0 if none)
- largestInterface = name with most methods (omit if no interfaces)`,
output: s.object({
interfaces: s.record(s.object({
methodCount: s.int,
hasOptionalMethods: s.boolean,
sourceFile: s.string,
})),
totalInterfaces: s.int,
averageMethodCount: s.number,
largestInterface: s.optional(s.string),
}),
tools: [countInterfaceMethods],
maxTurns: 6,
addons: [steering()],
});

export default tsInterfaceMethodCounter;
```
57 changes: 57 additions & 0 deletions skills/rig/samples/426-git-tag-annotation-extractor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 426 - Git Tag Annotation Extractor

```rig
import { agent, p, s, defineTool, repair } from "rig";
import { execSync } from "node:child_process";

const classifyTagType = defineTool("classifyTagType", {
description: "Classify a git tag as annotated, lightweight, or signed.",
parameters: s.object({ tagName: s.string }),
handler({ tagName }: { tagName: string }) {
try {
const objType = execSync(`git cat-file -t "refs/tags/${tagName}" 2>/dev/null || echo lightweight`, { encoding: "utf-8" }).trim();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] Same shell injection pattern as 423: tagName is interpolated into execSync shell commands. A tag named foo```; rm -rf .git would be passed directly to the shell.

💡 Fix

Use spawnSync for both calls:

import { spawnSync } from "node:child_process";

const typeResult = spawnSync("git", ["cat-file", "-t", `refs/tags/${tagName}`], { encoding: "utf-8" });
const objType = typeResult.status === 0 ? typeResult.stdout.trim() : "lightweight";

if (objType === "tag") {
const body = execSync(`git cat-file tag "refs/tags/${tagName}" 2>/dev/null || echo ""`, { encoding: "utf-8" });
if (/BEGIN PGP/.test(body)) return { type: "signed" as const, subject: "", date: "" };
const subjectMatch = body.match(/^subject (.+)$/m);
const dateMatch = body.match(/^tagger .+ (\d{4}-\d{2}-\d{2})/m);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The tagger date regex will never match — git stores tagger timestamps as Unix epoch (1697000000 +0000), not YYYY-MM-DD. dateMatch always returns null, so date is always "" for every annotated tag.

💡 Fix

Parse the epoch integer and convert it:

const dateMatch = body.match(/^tagger .+ (\d{10,})/m);
const date = dateMatch ? new Date(parseInt(dateMatch[1], 10) * 1000).toISOString().slice(0, 10) : "";

return {
type: "annotated" as const,
subject: subjectMatch ? subjectMatch[1] : "",
date: dateMatch ? dateMatch[1] : "",
};
}
return { type: "lightweight" as const, subject: "", date: "" };
} catch {
return { type: "lightweight" as const, subject: "", date: "" };
}
},
});

// Agent role: extract and classify all git tag annotations in the repository.
const gitTagAnnotationExtractor = agent({
model: "small",
instructions: p`Extract and classify git tag annotations.

All tags in repository:
${p.bash("git tag -l | head -50")}

For each tag name, call classifyTagType to determine type, subject, and date.
Build tags record keyed by tag name.
Count annotatedCount, lightweightCount, totalTags.`,
output: s.object({
tags: s.record(s.object({
type: s.enum("annotated", "lightweight", "signed"),
subject: s.optional(s.string),
date: s.optional(s.string),
})),
annotatedCount: s.int,
lightweightCount: s.int,
totalTags: s.int,
}),
tools: [classifyTagType],
addons: [repair()],
});

export default gitTagAnnotationExtractor;
```
Loading
Loading