From cc5189200e5cd803484db1ac4aa2a35182ff9bd1 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 6 Jul 2026 21:59:43 -0400 Subject: [PATCH] ci: add API reference docs generation workflow Auto-generates the API reference as AsciiDoc on each release and uploads it as a build artifact for the docs team to publish. --- .github/workflows/publish-api-docs.yml | 82 ++++++++ scripts/extract-cli-model.mjs | 228 ++++++++++++++++++++ scripts/render_adoc.py | 274 +++++++++++++++++++++++++ 3 files changed, 584 insertions(+) create mode 100644 .github/workflows/publish-api-docs.yml create mode 100644 scripts/extract-cli-model.mjs create mode 100644 scripts/render_adoc.py diff --git a/.github/workflows/publish-api-docs.yml b/.github/workflows/publish-api-docs.yml new file mode 100644 index 000000000..c1c4ce290 --- /dev/null +++ b/.github/workflows/publish-api-docs.yml @@ -0,0 +1,82 @@ +# ============================================================================= +# publish-api-docs.yml — agentcore-cli +# ============================================================================= +# Auto-generates the CLI command reference as AsciiDoc (.adoc) on every release +# by scraping `agentcore --help` for all commands (grouped by category), +# and uploads it as a build artifact for the docs team to publish. +# +# Pipeline: install released CLI -> scrape --help into doc-model JSON +# -> render .adoc -> upload artifact. +# +# NOTE: this generates and publishes the docs as an artifact only; landing them +# in the docs site is a separate step for now and may be automated later. +# ============================================================================= + +name: Publish API docs + +on: + release: + types: [published] + workflow_dispatch: {} + +permissions: + contents: read + +env: + ADOC_PREFIX: cli + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up Python (for the renderer) + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install the released CLI version + # Install the EXACT version from the release event, not @latest. + # `release: [published]` fires for both GA and preview releases, and + # previews publish under the `preview` dist-tag — so @latest would pull + # the wrong (previous GA) version and generate docs for it. + # On manual workflow_dispatch there is no release tag; fall back to latest. + run: | + if [ -n "$RELEASE_TAG" ]; then + npm install -g "@aws/agentcore@${RELEASE_TAG#v}" + else + npm install -g @aws/agentcore@latest + fi + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + + - name: Read CLI version + id: ver + run: echo "version=$(agentcore --version)" >> "$GITHUB_OUTPUT" + + - name: Extract doc-model JSON (scrape --help for all commands) + run: | + node scripts/extract-cli-model.mjs \ + --out doc-model.json \ + --version "${{ steps.ver.outputs.version }}" + + - name: Render .adoc + run: | + python scripts/render_adoc.py \ + --model doc-model.json \ + --out-dir out/adoc \ + --prefix "${ADOC_PREFIX}" + + - name: Upload API-docs artifact + uses: actions/upload-artifact@v4 + with: + name: api-docs-adoc + path: out/adoc + if-no-files-found: error diff --git a/scripts/extract-cli-model.mjs b/scripts/extract-cli-model.mjs new file mode 100644 index 000000000..fd0fc518d --- /dev/null +++ b/scripts/extract-cli-model.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +// ============================================================================= +// extract-cli-model.mjs — agentcore CLI -> doc-model JSON +// ============================================================================= +// Runs `agentcore --help` for every command, parses the Commander.js help +// output, and emits the SAME shared doc-model schema (v1) as the SDK extractors +// so the one shared renderer (_shared/render_adoc.py) produces consistent .adoc. +// +// Why --help scraping (not AST): it is 1:1 with what users actually see, and it +// survives refactors of the command source. Commander.js help is +// stable and easy to parse (Usage / Arguments / Options blocks). +// +// Decision: ALL ~30 commands, arranged into groups (one group == one .adoc). +// +// NOTE: if these workflows are later consolidated into a single shared reusable +// workflow, this script is vendored there and selected via `language: cli`. +// Standalone here for the draft. +// ============================================================================= +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { argv } from 'node:process'; + +// Command grouping (arranged properly, per the request). Mirrors the sections +// in agentcore-cli/docs/commands.md. Any command discovered from the binary but +// not listed here lands in the "Other" group so nothing is silently dropped. +const GROUPS = [ + { + id: 'project-lifecycle', + title: 'Project Lifecycle', + commands: ['create', 'deploy', 'dev', 'package', 'export', 'update', 'validate'], + }, + { + id: 'invocation', + title: 'Invocation & Runtime', + commands: ['invoke', 'exec', 'run', 'logs', 'traces', 'status', 'fetch', 'view'], + }, + { + id: 'resources', + title: 'Resource Management', + commands: ['add', 'remove', 'import'], + }, + { + id: 'evaluation', + title: 'Evaluation & Datasets', + commands: ['evals', 'batch-evaluations', 'dataset'], + }, + { + id: 'optimization', + title: 'Optimization & Config Bundles', + commands: ['config-bundle', 'promote', 'archive'], + }, + { + id: 'operations', + title: 'Operations & Settings', + commands: ['pause', 'resume', 'stop', 'config', 'telemetry', 'feedback'], + }, +]; + +// AGENTCORE_BIN may be a single command ("agentcore") OR a multi-token +// invocation ("node /path/to/index.mjs"). Split into [executable, ...prefixArgs] +// so execFileSync gets a real executable and forwards the rest as args — +// passing the whole string as argv[0] would make execFileSync look for an +// executable literally named "node /path/...", which fails silently to empty. +const BIN_TOKENS = (process.env.AGENTCORE_BIN || 'agentcore').split(/\s+/); +const BIN = BIN_TOKENS[0]; +const BIN_PREFIX_ARGS = BIN_TOKENS.slice(1); + +function getArg(flag) { + const i = argv.indexOf(flag); + return i >= 0 ? argv[i + 1] : undefined; +} + +function help(args) { + try { + return execFileSync(BIN, [...BIN_PREFIX_ARGS, ...args, '--help'], { + encoding: 'utf8', + // CLI launches a TUI without args; --help forces non-interactive text. + env: { ...process.env, NO_COLOR: '1', CI: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e) { + // Commander exits non-zero for some help paths; stdout still holds text. + return (e.stdout && e.stdout.toString()) || ''; + } +} + +// Parse a Commander.js help blob into { summary, signature, params, options }. +function parseHelp(text, name) { + const lines = text.split('\n'); + const out = { summary: '', signature: '', args: [], options: [] }; + let section = 'head'; + const descLines = []; + + for (const raw of lines) { + const line = raw.replace(/\s+$/, ''); + if (/^Usage:/.test(line)) { + out.signature = line.replace(/^Usage:\s*/, '').trim(); + section = 'desc'; + continue; + } + if (/^Arguments:/.test(line)) { + section = 'args'; + continue; + } + if (/^Options:/.test(line)) { + section = 'options'; + continue; + } + if (/^Commands:/.test(line)) { + section = 'commands'; + continue; + } + + if (section === 'desc') { + if (line.trim()) descLines.push(line.trim()); + } else if (section === 'args') { + const m = line.match(/^\s+(\S+)\s{2,}(.*)$/); + if (m) out.args.push({ name: m[1], type: null, required: true, description: m[2].trim() }); + } else if (section === 'options') { + const m = line.match(/^\s+(-[^\s].*?)\s{2,}(.*)$/); + if (m) out.options.push({ name: m[1].trim(), type: null, required: false, description: m[2].trim() }); + } + } + out.summary = descLines.join(' '); + return out; +} + +function entryForCommand(name) { + const raw = help([name]); + // Fail loudly on phantom commands / drift. A real command's help names itself + // in the usage line (`Usage: agentcore ...`); an unknown command falls + // back to the root usage (`Usage: agentcore [options] [command]`), which does + // NOT contain the name. Without this guard such commands would silently render + // as empty stubs (synthesized signature, no description/params). + if (!new RegExp(`^Usage:\\s+agentcore\\s+${name}\\b`, 'm').test(raw)) { + throw new Error( + `Command "${name}" produced no self-describing help output — it is likely ` + + `not a real CLI command (phantom/renamed). Remove it from GROUPS or fix the name.` + ); + } + const parsed = parseHelp(raw, name); + // subcommands (e.g. `add agent`, `remove tool`) show under "Commands:"— + // we surface the top-level command; nested ones can be expanded later. + return { + kind: 'command', + name: `agentcore ${name}`, + signature: parsed.signature || `agentcore ${name} [options]`, + summary: parsed.summary, + description: '', + // reuse params for both positional args and flags, flagged by required. + // The renderer already wraps param names in backticks, so don't add our + // own (that produced double-backticks). Drop the ubiquitous help flag. + params: [...parsed.args, ...parsed.options.filter(o => !/^-h,?\s|--help\b/.test(o.name))], + returns: null, + raises: [], + examples: [], + members: [], + }; +} + +function discoverCommands() { + // Parse the root help "Commands:" block so we don't miss anything new. + // In Commander output, a command entry starts at EXACTLY 2 spaces of indent + // (e.g. " deploy|dp [options] ...") while wrapped description lines are + // indented much deeper (~36 spaces). Matching only the 2-space indent avoids + // scraping the first word of a wrapped description ("locally", "past", ...). + // We also strip the "|alias" and trailing "[options]"/"" tokens. + const text = help([]); + const found = []; + let inCmds = false; + for (const line of text.split('\n')) { + if (/^Commands:/.test(line)) { + inCmds = true; + continue; + } + if (!inCmds) continue; + // stop if we hit a new top-level section (e.g. a footer) — non-indented text + if (line.trim() && !/^ {2}\S/.test(line) && !/^ {3,}/.test(line)) break; + const m = line.match(/^ {2}([a-z][a-z0-9-]*)(?:\|[a-z0-9-]+)?\b/); + if (m) found.push(m[1]); + } + return found; +} + +function main() { + const outPath = getArg('--out'); + const version = getArg('--version') || 'unknown'; + + const discovered = new Set(discoverCommands()); + const placed = new Set(); + const groups = []; + + for (const g of GROUPS) { + const entries = []; + for (const cmd of g.commands) { + entries.push(entryForCommand(cmd)); + placed.add(cmd); + } + groups.push({ id: g.id, title: g.title, summary: '', entries }); + } + + // Built-in meta-commands we intentionally don't document as API surface. + const IGNORED = new Set(['help']); + + // Catch anything the binary exposes that we didn't group — no silent drops. + const leftovers = [...discovered].filter(c => !placed.has(c) && !IGNORED.has(c)); + if (leftovers.length) { + process.stderr.write(`WARNING: ungrouped commands -> "Other": ${leftovers.join(', ')}\n`); + groups.push({ + id: 'other', + title: 'Other Commands', + summary: '', + entries: leftovers.map(entryForCommand), + }); + } + + const model = { + source: 'cli', + package: '@aws/agentcore', + version, + language: 'cli', + groups, + }; + writeFileSync(outPath, JSON.stringify(model, null, 2)); + process.stderr.write(`Wrote doc-model: ${groups.length} groups, version ${version}\n`); +} + +main(); diff --git a/scripts/render_adoc.py b/scripts/render_adoc.py new file mode 100644 index 000000000..f0276c050 --- /dev/null +++ b/scripts/render_adoc.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# ============================================================================= +# render_adoc.py — shared doc-model -> AsciiDoc renderer +# ============================================================================= +# Renders a normalized "doc-model" JSON (produced by any of the three +# extractors) into .adoc files for the documentation repository. +# +# It is deliberately source-agnostic: the Python extractor, the TypeScript +# TypeDoc extractor, and the CLI --help extractor all emit the SAME doc-model +# schema, so this one renderer produces consistent output for all three. +# +# NOTE: if these workflows are later consolidated into a single shared reusable +# workflow, this file is vendored there ONCE and every source repo's caller +# invokes it. Keep it dependency-free (stdlib only) so it drops cleanly into any +# runner. +# +# ----------------------------------------------------------------------------- +# doc-model schema (v1) — the contract every extractor must emit: +# { +# "source": "python-sdk" | "ts-sdk" | "cli", +# "package": "bedrock-agentcore", +# "version": "1.16.0", +# "language": "python" | "typescript" | "cli", +# "groups": [ # a group -> one .adoc file +# { +# "id": "runtime", # -> -runtime.adoc, and anchor id +# "title": "Runtime", +# "summary": "Runtime management and application context.", +# "entries": [ # classes / functions / commands +# { +# "kind": "class" | "function" | "command", +# "name": "BedrockAgentCoreApp", +# "signature": "BedrockAgentCoreApp(params)", +# "summary": "one-line summary", +# "description": "longer prose (optional)", +# "params": [ {"name","type","required","description"} ], +# "returns": {"type","description"} | null, +# "raises": [ {"type","description"} ], +# "examples": [ {"lang","code"} ], +# "members": [ , ... ] # methods on a class (recursive) +# } +# ] +# } +# ] +# } +# ----------------------------------------------------------------------------- + +import argparse +import json +import os +import re +import sys +import textwrap + +SCHEMA_VERSION = 1 + + +def esc(text): + """Escape AsciiDoc-significant characters in inline text.""" + if not text: + return "" + # Guard the couple of chars that start AsciiDoc markup in running prose. + return ( + text.replace("|", "\\|") + .replace("{", "\\{") + ) + + +# Match markdown code fences that may be indented (reST/Google docstrings often +# indent example blocks). `re.MULTILINE` lets ^ match each line start; the +# leading-whitespace groups are stripped from the captured code. +_FENCE_RE = re.compile(r"^[ \t]*```(\w*)[ \t]*\n(.*?)\n[ \t]*```[ \t]*$", re.DOTALL | re.MULTILINE) + + +def render_prose(text): + """Render description prose that may contain markdown ``` code fences. + + Docstrings/TSDoc frequently embed fenced code blocks in the description + (not just in @example). Left alone they leak literal backticks into the + AsciiDoc. Split the prose on fences: escape the prose spans, and convert + each fenced block into an AsciiDoc [source] block (verbatim, not escaped). + """ + if not text: + return [] + out = [] + pos = 0 + for m in _FENCE_RE.finditer(text): + before = text[pos:m.start()].strip() + if before: + out.append(esc(before)) + out.append("") + lang = m.group(1) or "" + out.append(f"[source,{lang}]" if lang else "[source]") + out.append("----") + # Dedent the captured code (fences are often indented in docstrings). + out.append(textwrap.dedent(m.group(2)).rstrip()) + out.append("----") + out.append("") + pos = m.end() + tail = text[pos:].strip() + if tail: + out.append(esc(tail)) + return out + + +def block(lines): + return "\n".join(lines) + + +def render_params(params, out): + if not params: + return + out.append("*Parameters*") + out.append("") + for p in params: + req = "" if p.get("required") else " _(optional)_" + typ = f"`{p['type']}`" if p.get("type") else "" + out.append(f"`{p['name']}`{req} {typ}::") + out.append(esc(p.get("description", "")) or "_No description._") + out.append("") + + +def render_returns(returns, out): + if not returns: + return + typ = f"`{returns['type']}` — " if returns.get("type") else "" + out.append("*Returns*") + out.append("") + out.append(f"{typ}{esc(returns.get('description', ''))}".strip()) + out.append("") + + +def render_raises(raises, out): + if not raises: + return + out.append("*Raises*") + out.append("") + for r in raises: + out.append(f"`{r.get('type', 'Error')}`:: {esc(r.get('description', ''))}") + out.append("") + + +def clean_example_code(code): + """Strip stray markdown fence lines from example code. + + Extractors try to remove ``` fences, but real docstrings put them mid-buffer + (e.g. a fence followed by extra "Notes:"/"Thread Safety:" prose swept into + the example). Since this text is already going inside an AsciiDoc [source] + block, any line that is just a fence is spurious — drop it, and drop trailing + non-code prose that follows a closing fence. + """ + lines = code.split("\n") + kept = [] + closed = False + for line in lines: + if re.match(r"^[ \t]*```", line): + # A closing fence marks the end of the real code; ignore the fence + # line itself and stop taking subsequent prose. + if kept: + closed = True + continue + if closed and line.strip() == "": + continue + if closed and line.strip(): + break # prose after the closing fence — not part of the example + kept.append(line) + return textwrap.dedent("\n".join(kept)).strip() + + +def render_examples(examples, out): + for ex in examples or []: + lang = ex.get("lang", "text") + code = clean_example_code(ex.get("code", "")) + if not code: + continue + out.append(f"[source,{lang}]") + out.append("----") + out.append(code) + out.append("----") + out.append("") + + +def render_entry(entry, level, out): + """Render a single class/function/command as an AsciiDoc section.""" + heading = "=" * level + name = entry.get("name", "") + out.append(f"{heading} {name}") + out.append("") + + sig = entry.get("signature") + if sig: + out.append("[source]") + out.append("----") + out.append(sig) + out.append("----") + out.append("") + + if entry.get("summary"): + out.extend(render_prose(entry["summary"])) + out.append("") + if entry.get("description"): + out.extend(render_prose(entry["description"])) + out.append("") + + render_params(entry.get("params"), out) + render_returns(entry.get("returns"), out) + render_raises(entry.get("raises"), out) + render_examples(entry.get("examples"), out) + + # methods / subcommands nest one heading level deeper + for member in entry.get("members", []): + render_entry(member, level + 1, out) + + +def render_group(group, model, prefix): + """Render one group -> one .adoc file body (string).""" + out = [] + gid = group["id"] + # AsciiDoc anchor so the TOC / other pages can xref into it. + out.append(f"[[{prefix}-{gid}]]") + out.append(f"= {group['title']}") + out.append("") + out.append( + f"_Auto-generated from `{model['package']}` " + f"v{model['version']} — do not edit by hand._" + ) + out.append("") + if group.get("summary"): + out.extend(render_prose(group["summary"])) + out.append("") + + for entry in group.get("entries", []): + render_entry(entry, level=2, out=out) + + return block(out).rstrip() + "\n" + + +def main(): + ap = argparse.ArgumentParser(description="Render doc-model JSON to .adoc") + ap.add_argument("--model", required=True, help="path to doc-model JSON") + ap.add_argument("--out-dir", required=True, help="output dir for .adoc files") + ap.add_argument( + "--prefix", + required=True, + help="filename + anchor prefix, e.g. 'python-sdk', 'ts-sdk', 'cli'", + ) + args = ap.parse_args() + + with open(args.model) as f: + model = json.load(f) + + os.makedirs(args.out_dir, exist_ok=True) + written = [] + for group in model.get("groups", []): + body = render_group(group, model, args.prefix) + fname = f"{args.prefix}-{group['id']}.adoc" + path = os.path.join(args.out_dir, fname) + with open(path, "w") as f: + f.write(body) + written.append(fname) + + # Emit the list of includes the caller can splice into the docs TOC file. + manifest = os.path.join(args.out_dir, f"{args.prefix}-includes.txt") + with open(manifest, "w") as f: + for fname in written: + f.write(f"include::{args.prefix}/{fname}[leveloffset=+1]\n") + + print(f"Rendered {len(written)} .adoc files to {args.out_dir}", file=sys.stderr) + for fname in written: + print(f" - {fname}", file=sys.stderr) + + +if __name__ == "__main__": + main()