From cec43973d8d6d0661e35b2857e4c2f60d2fd8436 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 09:29:30 -0500 Subject: [PATCH 1/2] feat(site): agent contract in llms.txt + FAQ/AEO with drift guards Make jamf-cli more discoverable and usable by LLM agents and answer engines, and guard the new hand-authored content against drift. llms.txt: - Add a "For AI Agents" operating contract: runtime discovery via `commands -o json`, unattended flags, the exit-code table + JSON error envelope, token-efficiency flags, and apply/destructive semantics. - Expand Output Formats (--select/--field/--compact, 50-row hint). - Guard the exit-code table against internal/exitcode (unit test). Site AEO (docs/site): - Add a visible FAQ section + matching FAQPage JSON-LD (shared text). - Add meta robots (max-snippet/max-image-preview). - Freshness signals: JSON-LD dateModified/softwareVersion and sitemap , plus a self-updating command count in the OG/Twitter meta, stamped at deploy from commands.json (fixes a stale 1,251 -> 1,299). Version parsing fix: - generator/site parseVersion now reads the JSON `version` field (the `version` command defaults to -o json), with a plain-text fallback; previously commands.json/llms.txt could record version "unknown". Drift guards (verify-site-output.sh): - FAQ questions must match between the visible section and FAQPage. - llms.txt flag names must exist in the CLI (--help globals plus commands.json per-command flags). - Every product in commands.json must be documented in llms.txt + FAQ. - The FAQ "over N commands" floor must not exceed the real count. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-site.yaml | 19 +++++ Makefile | 1 + docs/site/index.html | 110 +++++++++++++++++++++++++- docs/site/sitemap.xml | 7 ++ docs/site/style.css | 47 +++++++++++ generator/site/main.go | 33 ++++++-- generator/site/main_test.go | 68 ++++++++++++++++ scripts/verify-site-output.sh | 121 +++++++++++++++++++++++++++++ 8 files changed, 398 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy-site.yaml b/.github/workflows/deploy-site.yaml index e6958a24..7299831f 100644 --- a/.github/workflows/deploy-site.yaml +++ b/.github/workflows/deploy-site.yaml @@ -74,6 +74,25 @@ jobs: --llms-full-output ./docs/site/llms-full.txt \ $PREV_FLAG + - name: Stamp build freshness (date + version) into SEO assets + # index.html (JSON-LD dateModified/softwareVersion) and sitemap.xml + # () carry __BUILD_DATE__/__VERSION__ placeholders in the repo. + # Substitute them here, in the throwaway runner checkout, so deployed + # structured data signals freshness without committing volatile values. + # Version comes from commands.json, which the generator just wrote using + # the same cleaning logic as the on-page version badge (single source). + run: | + BUILD_DATE="$(date -u +%Y-%m-%d)" + VERSION="$(python3 -c "import json; print(json.load(open('docs/site/commands.json'))['version'])")" + COUNT="$(python3 -c "import json; print(format(json.load(open('docs/site/commands.json'))['commandCount'], ','))")" + sed -i "s/__BUILD_DATE__/${BUILD_DATE}/g; s/__VERSION__/${VERSION}/g; s/__COMMAND_COUNT__/${COUNT}/g" \ + docs/site/index.html docs/site/sitemap.xml + if grep -REn '__BUILD_DATE__|__VERSION__|__COMMAND_COUNT__' docs/site/index.html docs/site/sitemap.xml; then + echo "::error::Unsubstituted SEO placeholder(s) remain after stamping." >&2 + exit 1 + fi + echo "Stamped freshness: version=${VERSION} date=${BUILD_DATE} commands=${COUNT}" + - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/Makefile b/Makefile index a1e63540..eafb246b 100644 --- a/Makefile +++ b/Makefile @@ -191,6 +191,7 @@ verify-site-output: build --commands-json /tmp/jamf-cli-site/commands.json \ --llms-txt /tmp/jamf-cli-site/llms.txt \ --llms-full-txt /tmp/jamf-cli-site/llms-full.txt \ + --binary ./bin/jamf-cli \ --site-dir docs/site # Verify generated code is up to date (CI-safe) diff --git a/docs/site/index.html b/docs/site/index.html index b7b5bb29..f30d8308 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -4,6 +4,7 @@ + jamf-cli — Your fleet, one command away @@ -16,17 +17,20 @@ - + - + + and structured data for AI/search consumers. + __VERSION__ and __BUILD_DATE__ are substituted with the release version + and deploy date by the "Stamp build freshness" step in deploy-site.yaml. + They stay as literal placeholders in the repo (valid JSON strings). --> +
diff --git a/docs/site/sitemap.xml b/docs/site/sitemap.xml index a4185da3..a6b054ca 100644 --- a/docs/site/sitemap.xml +++ b/docs/site/sitemap.xml @@ -1,22 +1,29 @@ + https://jamf-concepts.github.io/jamf-cli/ + __BUILD_DATE__ weekly 1.0 https://jamf-concepts.github.io/jamf-cli/llms.txt + __BUILD_DATE__ weekly 0.8 https://jamf-concepts.github.io/jamf-cli/llms-full.txt + __BUILD_DATE__ weekly 0.8 https://jamf-concepts.github.io/jamf-cli/commands.json + __BUILD_DATE__ weekly 0.6 diff --git a/docs/site/style.css b/docs/site/style.css index 8fe08bfb..5d580061 100644 --- a/docs/site/style.css +++ b/docs/site/style.css @@ -2047,6 +2047,53 @@ pre.example-command.copied::after { text-align: center; } +#faq { + scroll-margin-top: 60px; + padding: 4rem 2rem; +} + +.faq-inner { + max-width: 760px; + margin: 0 auto; +} + +#faq h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 2rem; + text-align: center; +} + +.faq-item { + border-top: 1px solid var(--border); + padding: 1.25rem 0; +} + +.faq-item:last-child { + border-bottom: 1px solid var(--border); +} + +.faq-item h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.faq-item p { + color: var(--text-secondary); + line-height: 1.6; + font-size: 0.9rem; +} + +.faq-item code { + font-family: 'Geist Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.82em; + background: var(--page-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.1em 0.35em; +} + .install-grid { display: flex; flex-direction: column; diff --git a/generator/site/main.go b/generator/site/main.go index 836af901..35f08ba9 100644 --- a/generator/site/main.go +++ b/generator/site/main.go @@ -158,7 +158,14 @@ func renderLLMSTxt(d siteData) string { fmt.Fprintf(&b, "- Credentials never accepted via CLI flags or stdin (shell-history safe). Interactive prompts, env vars (`JAMF_*`, `JAMFPROTECT_*`), or keychain-backed config profiles.\n\n") fmt.Fprintf(&b, "## Output Formats\n\n") - fmt.Fprintf(&b, "Every command supports `-o {table,plain,json,yaml,csv}` plus `--field ` for jq-free scalar extraction. Pipe-friendly, scriptable, NO_COLOR aware.\n\n") + fmt.Fprintf(&b, "Every command supports `-o {table,plain,json,yaml,csv}` (default `json`). Shrink large responses with `--select id,general.name` (project dot-path fields), `--field general.name` (extract one scalar), or `--compact` (drop arrays and nested objects). Lists over 50 rows print a `hint:` to stderr suggesting how to narrow. Pipe-friendly, scriptable, NO_COLOR aware.\n\n") + + fmt.Fprintf(&b, "## For AI Agents\n\n") + fmt.Fprintf(&b, "**Discover commands at runtime.** `jamf-cli commands -o json` emits the full command tree (path, description, flags, aliases, product, group). Prefer it over guessing command names.\n\n") + fmt.Fprintf(&b, "**Run unattended.** Pass `--no-input` (fail instead of prompting) and `--quiet`. Authenticate with `JAMF_*` / `JAMFPROTECT_*` env vars — never flags. Set `JAMF_CLI_ARGS='--quiet --no-input'` to apply both to every invocation.\n\n") + fmt.Fprintf(&b, "**Parse outcomes.** Default output is JSON. Exit codes are stable: 0 success · 1 general · 2 usage · 3 auth · 4 not-found · 5 permission · 6 rate-limited. With `-o json`, failures print `{\"error\",\"message\",\"exitCode\"}`, so success and failure are both machine-readable.\n\n") + fmt.Fprintf(&b, "**Stay within context.** Use `--select`, `--field`, and `--compact` to avoid pulling multi-thousand-line payloads you don't need.\n\n") + fmt.Fprintf(&b, "**Mutate safely.** `apply` is a name-based upsert — prefer it over create/update for idempotency. Destructive commands require `--yes` (bulk also requires `--confirm-destructive`). Preview any write with `--dry-run`/`-n`.\n\n") fmt.Fprintf(&b, "## Optional\n\n") fmt.Fprintf(&b, "- [Source code (Go)](https://github.com/Jamf-Concepts/jamf-cli)\n") @@ -369,12 +376,10 @@ func splitCSV(s string) []string { } func parseVersion(output string) string { - line, _, _ := strings.Cut(output, "\n") - fields := strings.Fields(line) - if len(fields) < 2 { + v := rawVersion(output) + if v == "" { return "unknown" } - v := fields[1] // Strip git-describe suffix (e.g. "v1.2.0-52-gffc0b5a-dirty" → "v1.2.0") if parts := strings.SplitN(v, "-", 2); len(parts) == 2 && strings.ContainsAny(parts[1], "0123456789") { if _, err := strconv.Atoi(strings.Split(parts[1], "-")[0]); err == nil { @@ -385,3 +390,21 @@ func parseVersion(output string) string { v = strings.TrimPrefix(v, "v") return v } + +// rawVersion extracts the version string from `jamf-cli version` output. The +// command defaults to JSON (-o json), so prefer the structured "version" +// field; fall back to the plain-text "jamf-cli vX.Y.Z" shape for older +// binaries. Returns "" when no version can be found. +func rawVersion(output string) string { + var meta struct { + Version string `json:"version"` + } + if err := json.Unmarshal([]byte(output), &meta); err == nil && meta.Version != "" { + return meta.Version + } + line, _, _ := strings.Cut(output, "\n") + if fields := strings.Fields(line); len(fields) >= 2 { + return fields[1] + } + return "" +} diff --git a/generator/site/main_test.go b/generator/site/main_test.go index 7ad6531a..6e231999 100644 --- a/generator/site/main_test.go +++ b/generator/site/main_test.go @@ -4,7 +4,11 @@ package main import ( "encoding/json" + "fmt" + "strings" "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" ) func TestTransformCommands(t *testing.T) { @@ -280,6 +284,26 @@ func TestParseVersion(t *testing.T) { input: "jamf-cli v2.0.0-beta.1\n commit: abc1234\n", want: "2.0.0-beta.1", }, + { + name: "json release (default -o json output)", + input: `{ + "version": "1.18.0", + "commit": "c71ad8a", + "built": "2026-05-31T05:22:01Z", + "specProVersion": "unknown" +}`, + want: "1.18.0", + }, + { + name: "json dirty dev build strips git-describe suffix", + input: `{"version":"v1.17.0-25-g74846ff-dirty","commit":"74846ff"}`, + want: "1.17.0", + }, + { + name: "json pre-release preserved", + input: `{"version":"v2.0.0-beta.1"}`, + want: "2.0.0-beta.1", + }, { name: "empty output", input: "", @@ -296,3 +320,47 @@ func TestParseVersion(t *testing.T) { }) } } + +// TestRenderLLMSTxt_AgentContract guards the "For AI Agents" section. The +// exit-code table is the load-bearing part: it must stay in lockstep with +// internal/exitcode. We interpolate each constant's value here, so renaming +// or renumbering a code in exitcode.go without updating llms.txt fails CI. +func TestRenderLLMSTxt_AgentContract(t *testing.T) { + out := renderLLMSTxt(siteData{Version: "1.0.0", CommandCount: 1200}) + + if !strings.Contains(out, "## For AI Agents") { + t.Fatalf("llms.txt is missing the \"For AI Agents\" section") + } + + exitCodes := []struct { + code int + label string + }{ + {exitcode.Success, "success"}, + {exitcode.General, "general"}, + {exitcode.Usage, "usage"}, + {exitcode.Authentication, "auth"}, + {exitcode.NotFound, "not-found"}, + {exitcode.PermissionDenied, "permission"}, + {exitcode.RateLimited, "rate-limited"}, + } + for _, ec := range exitCodes { + want := fmt.Sprintf("%d %s", ec.code, ec.label) + if !strings.Contains(out, want) { + t.Errorf("exit-code table out of sync with internal/exitcode: missing %q", want) + } + } + + // Spot-check the rest of the contract so a silent deletion is caught. + for _, want := range []string{ + "jamf-cli commands -o json", + "--no-input", + "--select", + "--confirm-destructive", + "apply", + } { + if !strings.Contains(out, want) { + t.Errorf("agent contract missing %q", want) + } + } +} diff --git a/scripts/verify-site-output.sh b/scripts/verify-site-output.sh index 40fee11a..317a9307 100755 --- a/scripts/verify-site-output.sh +++ b/scripts/verify-site-output.sh @@ -27,6 +27,7 @@ COMMANDS_JSON="docs/site/commands.json" SITE_DIR="docs/site" LLMS_TXT="" LLMS_FULL_TXT="" +BINARY="" while [[ $# -gt 0 ]]; do case "$1" in @@ -34,6 +35,7 @@ while [[ $# -gt 0 ]]; do --site-dir) SITE_DIR="$2"; shift 2 ;; --llms-txt) LLMS_TXT="$2"; shift 2 ;; --llms-full-txt) LLMS_FULL_TXT="$2"; shift 2 ;; + --binary) BINARY="$2"; shift 2 ;; -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \?//' exit 0 @@ -134,6 +136,125 @@ for i, m in enumerate(matches, 1): PY fi +echo "==> FAQ <-> FAQPage consistency in $SITE_DIR/index.html" +if [[ ! -f "$SITE_DIR/index.html" ]]; then + fail "$SITE_DIR/index.html missing" +else + python3 - "$SITE_DIR/index.html" <<'PY' || ERRORS=$((ERRORS + 1)) +import json, re, sys +html = open(sys.argv[1]).read() + +# Questions declared in the FAQPage structured data. +faq = None +for m in re.findall(r']+type="application/ld\+json"[^>]*>(.*?)', html, re.DOTALL): + try: + data = json.loads(m) + except json.JSONDecodeError: + continue + if data.get("@type") == "FAQPage": + faq = data + break +if faq is None: + print(" FAIL: no FAQPage JSON-LD block found in index.html", file=sys.stderr) + sys.exit(1) +jsonld_qs = [q.get("name", "").strip() for q in faq.get("mainEntity", [])] + +# Questions a human sees in the visible #faq section. +sec = re.search(r'
', html, re.DOTALL) +if not sec: + print(" FAIL: no visible
found in index.html", file=sys.stderr) + sys.exit(1) +visible_qs = [re.sub(r'<[^>]+>', '', h).strip() + for h in re.findall(r'

(.*?)

', sec.group(0), re.DOTALL)] + +missing = [q for q in jsonld_qs if q not in visible_qs] +if missing: + print(" FAIL: FAQPage questions missing from the visible #faq section:", file=sys.stderr) + for q in missing: + print(f" - {q!r}", file=sys.stderr) + print(" The structured data and the on-page FAQ must stay in sync.", file=sys.stderr) + sys.exit(1) +print(f" PASS: all {len(jsonld_qs)} FAQPage questions present in the visible #faq section") +PY +fi + +echo "==> llms.txt flag names exist in the CLI ($LLMS_TXT)" +if [[ -z "$BINARY" ]]; then + echo " SKIP: --binary not provided; cannot validate flag names against the CLI" +elif [[ ! -x "$BINARY" ]]; then + fail "--binary $BINARY is not executable" +elif [[ ! -s "$LLMS_TXT" ]]; then + fail "$LLMS_TXT missing; cannot validate flag names" +else + # Authoritative flag set = global/persistent flags (root --help) plus every + # per-command local flag (commands.json). The agent contract must not name a + # flag outside that union. + global_flags="$("$BINARY" --help 2>&1 | grep -oE '\-\-[a-z][a-z0-9-]+' | sort -u)" + python3 - "$LLMS_TXT" "$COMMANDS_JSON" "$global_flags" <<'PY' || ERRORS=$((ERRORS + 1)) +import json, re, sys +llms = open(sys.argv[1]).read() +cmds = json.load(open(sys.argv[2])) +valid = set(sys.argv[3].split()) +for c in cmds["commands"]: + for f in (c.get("flags") or []): + valid.add(f) +named = set(re.findall(r'(? product coverage & command count in llms.txt / FAQ" +if [[ ! -s "$COMMANDS_JSON" || ! -f "$SITE_DIR/index.html" ]]; then + fail "commands.json or index.html missing; cannot validate product/count docs" +else + python3 - "$COMMANDS_JSON" "$LLMS_TXT" "$SITE_DIR/index.html" <<'PY' || ERRORS=$((ERRORS + 1)) +import json, re, sys +cmds = json.load(open(sys.argv[1])) +llms = open(sys.argv[2]).read() +html = open(sys.argv[3]).read() + +labels = {"platform": "Jamf Platform", "pro": "Jamf Pro", + "protect": "Jamf Protect", "school": "Jamf School"} +products = sorted({c.get("product") for c in cmds["commands"] if c.get("product")}) + +m = re.search(r'
', html, re.DOTALL) +faq = m.group(0) if m else "" + +problems = [] +for p in products: + label = labels.get(p) + if not label: + problems.append(f"unknown product {p!r}: add a display name here and mention it in llms.txt + the FAQ") + continue + if label not in llms: + problems.append(f"product {label!r} in commands.json but not mentioned in llms.txt") + if label not in faq: + problems.append(f"product {label!r} in commands.json but not mentioned in the FAQ") + +# The FAQ states a rounded floor ("over N commands"); reality must stay above it. +count = cmds["commandCount"] +fm = re.search(r'over\s+([\d,]+)\s+commands', faq) +if fm: + floor = int(fm.group(1).replace(",", "")) + if count < floor: + problems.append(f"FAQ claims 'over {fm.group(1)} commands' but commands.json has only {count}") + +if problems: + print(" FAIL: product/count documentation drift:", file=sys.stderr) + for x in problems: + print(f" - {x}", file=sys.stderr) + sys.exit(1) +print(f" PASS: {len(products)} products documented; FAQ floor <= {count} commands") +PY +fi + echo "==> Pro pillar coverage in $SITE_DIR/catalog.js" if [[ ! -f "$SITE_DIR/catalog.js" ]]; then fail "$SITE_DIR/catalog.js missing" From fd85860c7abe5c481865af993f2c3535de1e0c6a Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Sun, 31 May 2026 10:54:45 -0500 Subject: [PATCH 2/2] fix(site): guard FAQ answer-text parity + run flag guard in preflight Address PR review nits: - verify-site-output.sh: the FAQ<->FAQPage guard now also compares answer text (acceptedAnswer.text vs the visible

), not just question text. Google's FAQ structured-data policy requires the markup answer to match the visible answer; question-only parity let answers drift silently. Tags are stripped, HTML entities decoded, and whitespace normalized before comparing. Verified to fail on an injected visible-answer change and pass on the in-sync content. - site-preflight.yaml: pass --binary ./bin/jamf-cli to verify-site-output.sh so the llms.txt flag-name guard runs on the release-tag preflight path too (previously only ci.yaml's make target exercised it; the binary is already built one step earlier). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/site-preflight.yaml | 1 + scripts/verify-site-output.sh | 41 +++++++++++++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/site-preflight.yaml b/.github/workflows/site-preflight.yaml index a0d64053..003c5187 100644 --- a/.github/workflows/site-preflight.yaml +++ b/.github/workflows/site-preflight.yaml @@ -76,4 +76,5 @@ jobs: --commands-json docs/site/commands.json \ --llms-txt docs/site/llms.txt \ --llms-full-txt docs/site/llms-full.txt \ + --binary ./bin/jamf-cli \ --site-dir docs/site diff --git a/scripts/verify-site-output.sh b/scripts/verify-site-output.sh index 317a9307..22551958 100755 --- a/scripts/verify-site-output.sh +++ b/scripts/verify-site-output.sh @@ -142,9 +142,17 @@ if [[ ! -f "$SITE_DIR/index.html" ]]; then else python3 - "$SITE_DIR/index.html" <<'PY' || ERRORS=$((ERRORS + 1)) import json, re, sys +from html import unescape html = open(sys.argv[1]).read() -# Questions declared in the FAQPage structured data. +def norm(s): + # Strip inline tags (e.g. ), decode entities, collapse whitespace — + # so the visible markup and the plain-text JSON-LD compare apples to apples. + s = re.sub(r'<[^>]+>', '', s) + s = unescape(s) + return re.sub(r'\s+', ' ', s).strip() + +# Question -> answer declared in the FAQPage structured data. faq = None for m in re.findall(r']+type="application/ld\+json"[^>]*>(.*?)', html, re.DOTALL): try: @@ -157,24 +165,33 @@ for m in re.findall(r']+type="application/ld\+json"[^>]*>(.*?) answer a human sees in the visible #faq section. sec = re.search(r'

', html, re.DOTALL) if not sec: print(" FAIL: no visible
found in index.html", file=sys.stderr) sys.exit(1) -visible_qs = [re.sub(r'<[^>]+>', '', h).strip() - for h in re.findall(r'

(.*?)

', sec.group(0), re.DOTALL)] +visible = {norm(h): norm(p) + for h, p in re.findall(r'

(.*?)

\s*

(.*?)

', sec.group(0), re.DOTALL)} -missing = [q for q in jsonld_qs if q not in visible_qs] -if missing: - print(" FAIL: FAQPage questions missing from the visible #faq section:", file=sys.stderr) - for q in missing: - print(f" - {q!r}", file=sys.stderr) - print(" The structured data and the on-page FAQ must stay in sync.", file=sys.stderr) +# Google requires the structured-data answer to match the visible answer, so +# check question presence AND answer-text parity (one is for humans, one for +# answer engines — they must not drift apart). +problems = [] +for q, ans in jsonld.items(): + if q not in visible: + problems.append(f"question not shown to humans: {q!r}") + elif visible[q] != ans: + problems.append(f"answer text differs between JSON-LD and visible FAQ for {q!r}") +if problems: + print(" FAIL: FAQPage <-> visible #faq drift:", file=sys.stderr) + for x in problems: + print(f" - {x}", file=sys.stderr) + print(" The structured data and the on-page FAQ (questions and answers) must stay in sync.", file=sys.stderr) sys.exit(1) -print(f" PASS: all {len(jsonld_qs)} FAQPage questions present in the visible #faq section") +print(f" PASS: all {len(jsonld)} FAQPage questions and answers match the visible #faq section") PY fi