Files
rs-sdk/sdk/generate-api-docs.ts
max bittkerandClaude Opus 5.5 df3d8888ae feat(sdk,mcp): cancellable execute_code, bot.mineRock, bounded walkTo
execute_code
- Cancellation now reaches inside the SDK via an AsyncLocalStorage scope:
  timeout, MCP cancel, or a newer call rejects pending waits and blocks
  further action packets, including those inside walkTo().
- One execution per bot: a new call supersedes the running one and starts
  once it has unwound; disconnect_bot does the same.
- Cancelled user code parks on bot/sdk calls instead of throwing, so a
  catch-all loop can't spin and starve the server.
- Console capture is routed per execution, so concurrent bots no longer
  clobber each other's output; leftover logs go to stderr.
- Stale world state warns and still runs, so a stale client can be diagnosed.
- Credentials are redacted from logs, results and launch messages.

SDK
- bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that
  success can mean the action only started.
- walkTo() takes options.timeout (default 120s), refuses stale state, and
  reports the real last position.
- runScript uses the same cancellation scope for timeouts and signals.

Docs trimmed of duplicated or stale execute_code text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:19 -04:00

288 lines
12 KiB
TypeScript

#!/usr/bin/env bun
/**
* Generate sdk/API.md from the TypeScript syntax tree.
*
* Usage:
* bun sdk/generate-api-docs.ts
* bun sdk/generate-api-docs.ts --check
*/
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import ts from 'typescript';
interface MethodDoc {
name: string;
signature: string;
description: string;
}
interface PropertyDoc {
signature: string;
description: string;
}
interface TypeDoc {
name: string;
description: string;
properties: PropertyDoc[];
}
interface ApiModel {
botActionsMethods: MethodDoc[];
sdkMethods: MethodDoc[];
resultTypes: TypeDoc[];
}
function sourceFile(path: string, source: string): ts.SourceFile {
return ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
}
function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
return ts.canHaveModifiers(node) && Boolean(ts.getModifiers(node)?.some(modifier => modifier.kind === kind));
}
function isPublic(node: ts.Node): boolean {
return !hasModifier(node, ts.SyntaxKind.PrivateKeyword) &&
!hasModifier(node, ts.SyntaxKind.ProtectedKeyword);
}
function normalizeCode(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
function descriptionFor(node: ts.Node): string {
const docs = ts.getJSDocCommentsAndTags(node).filter(ts.isJSDoc);
const comment = docs.at(-1)?.comment;
return normalizeCode(ts.getTextOfJSDocComment(comment) ?? '');
}
function methodName(node: ts.MethodDeclaration, file: ts.SourceFile): string {
if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name) || ts.isNumericLiteral(node.name)) {
return node.name.text;
}
return normalizeCode(node.name.getText(file));
}
function methodSignature(node: ts.MethodDeclaration, file: ts.SourceFile): string {
const asyncPrefix = hasModifier(node, ts.SyntaxKind.AsyncKeyword) ? 'async ' : '';
const typeParameters = node.typeParameters?.length
? `<${node.typeParameters.map(parameter => normalizeCode(parameter.getText(file))).join(', ')}>`
: '';
const parameters = node.parameters
.map(parameter => normalizeCode(parameter.getText(file)))
.join(', ');
const returnType = node.type ? normalizeCode(node.type.getText(file)) : 'unknown';
return `${asyncPrefix}${methodName(node, file)}${typeParameters}(${parameters}): ${returnType}`;
}
function extractPublicClassMethods(
path: string,
source: string,
className: string
): MethodDoc[] {
const file = sourceFile(path, source);
const classNode = file.statements.find(
(statement): statement is ts.ClassDeclaration =>
ts.isClassDeclaration(statement) && statement.name?.text === className
);
if (!classNode) {
throw new Error(`Class ${className} was not found in ${path}`);
}
return classNode.members
.filter((member): member is ts.MethodDeclaration =>
ts.isMethodDeclaration(member) && isPublic(member)
)
.map(method => ({
name: methodName(method, file),
signature: methodSignature(method, file),
description: descriptionFor(method),
}));
}
function extractResultTypes(path: string, source: string): TypeDoc[] {
const file = sourceFile(path, source);
return file.statements
.filter((statement): statement is ts.InterfaceDeclaration =>
ts.isInterfaceDeclaration(statement) &&
hasModifier(statement, ts.SyntaxKind.ExportKeyword) &&
(statement.name.text.endsWith('Result') || statement.name.text.endsWith('State') || statement.name.text.startsWith('GE') || statement.name.text.startsWith('Market'))
)
.map(statement => ({
name: statement.name.text,
description: descriptionFor(statement),
properties: statement.members
.filter((member): member is ts.PropertySignature =>
ts.isPropertySignature(member) && Boolean(member.name)
)
.map(property => ({
signature: normalizeCode(property.getText(file)).replace(/;$/, ''),
description: descriptionFor(property),
})),
}));
}
function markdownCell(text: string): string {
return text
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ');
}
function methodCategory(name: string, surface: 'actions' | 'sdk'): string {
const lower = name.toLowerCase();
if (name.includes('GE') || name.startsWith('getMarket')) return 'Grand Exchange';
if (surface === 'actions') {
if (lower.includes('dialog') || lower.includes('blocking') || lower.includes('tutorial')) return 'UI & Dialog';
if (lower.includes('walk')) return 'Movement';
if (lower.includes('mine')) return 'Mining';
if (lower.includes('fletch') || lower.includes('craft') || lower.includes('smith') || lower.includes('enchant') || lower.includes('string')) {
return 'Crafting & Smithing';
}
if (lower.includes('attack') || lower.includes('equip') || lower.includes('eat') || lower.includes('cast')) {
return 'Combat & Equipment';
}
if (lower.includes('chop') || lower.includes('burn')) return 'Woodcutting & Firemaking';
if (lower.includes('pickup')) return 'Items & Inventory';
if (lower.includes('door')) return 'Doors';
if (lower.includes('talk') || lower.includes('interact') || lower.includes('pickpocket')) return 'NPC & Object Interaction';
if (lower.includes('trade')) return 'Player Trading';
if (lower.includes('shop') || lower.includes('buy') || lower.includes('sell')) return 'Shopping';
if (lower.includes('bank') || lower.includes('deposit') || lower.includes('withdraw')) return 'Banking';
if (lower.includes('wait')) return 'Condition Waiting';
if (lower.includes('prayer')) return 'Prayer';
return 'Other';
}
if (lower.startsWith('get') || (lower.startsWith('find') && !lower.includes('path')) || lower.startsWith('is')) {
return 'State Access';
}
if (lower.startsWith('scan')) return 'On-Demand Scanning';
if (lower.startsWith('send') || lower === 'say' || lower === 'clickdialogbytext') return 'Raw Actions';
if (lower.includes('path')) return 'Pathfinding';
if (lower.includes('wait')) return 'Condition Waiting';
if (lower.includes('connect') || lower.includes('connection') || lower.startsWith('onstate')) return 'Connection & Subscriptions';
return 'Other';
}
function renderMethodGroups(
lines: string[],
methods: MethodDoc[],
surface: 'actions' | 'sdk'
): void {
const groups = new Map<string, MethodDoc[]>();
for (const method of methods) {
const category = methodCategory(method.name, surface);
const group = groups.get(category) ?? [];
group.push(method);
groups.set(category, group);
}
for (const [category, group] of groups) {
lines.push(`### ${category}`, '', '| Signature | Description |', '|---|---|');
for (const method of group) {
const description = method.description || '_No description provided._';
lines.push(`| \`${markdownCell(method.signature)}\` | ${markdownCell(description)} |`);
}
lines.push('');
}
}
function renderApiMarkdown(model: ApiModel): string {
const lines: string[] = [
'# SDK API Reference',
'',
'> Auto-generated from TypeScript source. Do not edit directly.',
'> Run `bun run docs:api` to regenerate or `bun run docs:api:check` to verify it.',
'',
'## Execution model',
'',
'- `bot.*` methods are high-level helpers. They attempt to observe method-specific evidence such as movement, inventory, XP, dialog, or state changes. When a method returns a result, inspect its `success` and any `reason` or `message`: the strength of completion evidence varies by method.',
'- `sdk.send*` methods are low-level browser-client dispatches. A successful `ActionResult` means the enhanced browser client accepted and dispatched the command; it does **not** prove that the game server processed it or that the intended game effect occurred.',
'- Methods whose signature returns `Promise<...>` are asynchronous and must be awaited. In particular, `scanNearbyLocs()` and `scanGroundItems()` return promises.',
'- `getInventory().length` is the number of occupied slots. `InventoryItem.count` is the quantity in that one slot, and `findInventoryItem()` returns the first matching slot rather than an aggregate across matching slots.',
'',
'## BotActions (high-level)',
'',
];
renderMethodGroups(lines, model.botActionsMethods, 'actions');
lines.push('---', '', '## BotSDK (low-level)', '');
renderMethodGroups(lines, model.sdkMethods, 'sdk');
lines.push('---', '', '## Result and state types', '');
for (const type of model.resultTypes) {
lines.push(`### ${type.name}`, '');
if (type.description) lines.push(type.description, '');
lines.push('```typescript', `interface ${type.name} {`);
for (const property of type.properties) {
if (property.description) {
lines.push(` /** ${property.description.replace(/\*\//g, '*\\/')} */`);
}
lines.push(` ${property.signature};`);
}
lines.push('}', '```', '');
}
return `${lines.join('\n').trimEnd()}\n`;
}
export async function buildApiModel(sdkDir = import.meta.dir): Promise<ApiModel> {
const actionsPath = join(sdkDir, 'actions.ts');
const indexPath = join(sdkDir, 'index.ts');
const typesPath = join(sdkDir, 'types.ts');
const gePath = join(sdkDir, 'ge-types.d.ts');
const marketPath = join(sdkDir, 'market.ts');
const [actionsSource, indexSource, typesSource, geSource, marketSource] = await Promise.all([
readFile(actionsPath, 'utf8'),
readFile(indexPath, 'utf8'),
readFile(typesPath, 'utf8'),
readFile(gePath, 'utf8'),
readFile(marketPath, 'utf8'),
]);
return {
botActionsMethods: extractPublicClassMethods(actionsPath, actionsSource, 'BotActions'),
sdkMethods: extractPublicClassMethods(indexPath, indexSource, 'BotSDK'),
resultTypes: [...extractResultTypes(typesPath, typesSource), ...extractResultTypes(gePath, geSource), ...extractResultTypes(marketPath, marketSource)],
};
}
export async function generateApiMarkdown(sdkDir = import.meta.dir): Promise<string> {
return renderApiMarkdown(await buildApiModel(sdkDir));
}
async function main(): Promise<void> {
const check = process.argv.includes('--check');
const sdkDir = import.meta.dir;
const outputPath = join(sdkDir, 'API.md');
const model = await buildApiModel(sdkDir);
const markdown = renderApiMarkdown(model);
if (check) {
const current = await readFile(outputPath, 'utf8').catch(() => '');
if (current !== markdown) {
throw new Error('sdk/API.md is stale. Run `bun run docs:api` and commit the result.');
}
console.log(`Verified ${outputPath}`);
} else {
await writeFile(outputPath, markdown);
console.log(`Generated ${outputPath}`);
}
console.log(` - ${model.botActionsMethods.length} BotActions methods`);
console.log(` - ${model.sdkMethods.length} BotSDK methods`);
console.log(` - ${model.resultTypes.length} result/state types`);
}
if (import.meta.main) {
main().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}