diff --git a/CHANGELOG.md b/CHANGELOG.md index 15672a9..a5c321f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ installed copy can be traced back to the release that applied it. This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- The docs site has a changelog page. `site/changelog.html` is generated from + this file by `scripts/build-changelog.js` (`npm run build:changelog`) and + committed, because GitHub Pages serves `site/` verbatim and this file lives + outside `site/`, so the page cannot fetch it at runtime. `npm test` re-renders + and fails when the committed page has drifted, so this file stays the single + source of truth. The renderer supports only the markdown used here and fails + loudly on anything else, rather than printing literal markup on the site. + +### Changed + +- The mobile nav now collapses below 940px instead of 900px. The new Changelog + link makes the widest desktop nav row need about 930px, and between the old + breakpoint and that width the right-hand header pill ran past the rail and + gave every page a horizontal scroll. A test pins the breakpoint against the + measured width. + +### Fixed + +- The theme toggle's moon rendered as a lopsided blob rather than a crescent. + Its inner arc asked for radius 7 across endpoints 16.3 apart; per the SVG + spec a radius too small to span its endpoints is scaled up until it fits, so + the terminator became a semicircle mirroring the outer edge and the shape + lost its taper. It also overflowed the top and right of its 24x24 viewBox. + Replaced with a crescent whose arcs are geometrically consistent, on all + three pages. + ## 1.1.0 - 2026-07-27 ### Added diff --git a/docs/testing.md b/docs/testing.md index f05f221..13fb487 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,6 +14,27 @@ npm run check-profiles bundle's prompts with installed copies under `~/.config/opencode/agent/`. `check-profiles` verifies the model IDs in shipped profiles. +### Changelog site page + +`site/changelog.html` is generated from `CHANGELOG.md` and committed, because +GitHub Pages serves `site/` verbatim and the changelog lives outside it. After +editing `CHANGELOG.md`, regenerate and commit the page: + +```powershell +npm run build:changelog +``` + +`npm test` fails when the committed page has drifted from the changelog, so a +forgotten rebuild is caught before release rather than shipping a stale page. +`node scripts/build-changelog.js --check` reports the same drift without +writing, which is the form to use in a pre-commit hook. + +Only the markdown `CHANGELOG.md` actually uses is supported: `##`/`###` +headings, bullets with wrapped continuation lines, paragraphs, inline code, +links, and emphasis. Anything else (fenced code, tables, nested bullets) makes +the build fail with the offending line rather than rendering as literal markup. +Extend the renderer, or reword the entry. + To validate the lint fixture, run `npm run lint` with `test-playground/` as the working directory. Build and plan agents should use the tool's working-directory parameter because `npm --prefix test-playground run lint` may not match their diff --git a/package.json b/package.json index 0119e6f..74c66dd 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "scripts": { "test": "node --test", "test:integration": "node scripts/run-integration.js", + "build:changelog": "node scripts/build-changelog.js", "check-install": "node scripts/check-install.js", "check-profiles": "node scripts/check-profiles.js" } diff --git a/scripts/build-changelog.js b/scripts/build-changelog.js new file mode 100644 index 0000000..694da3b --- /dev/null +++ b/scripts/build-changelog.js @@ -0,0 +1,431 @@ +'use strict'; + +// Renders CHANGELOG.md into the generated regions of site/changelog.html. +// +// The site is static (GitHub Pages serves site/ verbatim, no build step at +// deploy time), and CHANGELOG.md is not inside site/, so the page cannot fetch +// it at runtime. Instead this script writes the release history into the page +// and the output is committed. test/changelog-site.test.js re-runs the render +// and fails if the committed page has drifted, so the changelog stays the +// single source of truth for what the site shows. +// +// node scripts/build-changelog.js # write the page +// node scripts/build-changelog.js --check # fail if the page is stale +// +// The markdown subset is deliberately small - exactly what CHANGELOG.md uses. +// Anything outside it throws rather than rendering as literal markup, so a new +// construct is a loud build failure instead of a silent typo on the site. + +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.join(__dirname, '..'); +const CHANGELOG_PATH = path.join(repoRoot, 'CHANGELOG.md'); +const PAGE_PATH = path.join(repoRoot, 'site', 'changelog.html'); + +const MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +const toLf = (text) => text.replace(/\r\n/g, '\n'); + +/* ---- Parsing ---------------------------------------------------------- */ + +/** A release heading is `## ` with an optional `- `. */ +function parseReleaseHeading(heading) { + const match = heading.match(/^\[?([^\]\s]+)\]?(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?$/); + if (!match) { + throw new Error( + `unsupported release heading "## ${heading}" - expected "## " or "## - YYYY-MM-DD"` + ); + } + const version = match[1]; + const unreleased = /^unreleased$/i.test(version); + const slug = version.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return { + version, + date: match[2] ?? null, + unreleased, + // Numeric versions get a `v` prefix so the fragment reads as #v1-1-0. + id: /^\d/.test(version) ? `v${slug}` : slug, + blocks: [], + }; +} + +/** Reject the constructs this renderer does not implement, loudly. */ +function rejectUnsupported(line, lineNumber) { + const unsupported = [ + [/^\s*(```|~~~)/, 'fenced code block'], + [/^\s{2,}[-*+] /, 'nested list item'], + [/^\s*\d+\. /, 'ordered list'], + [/^\s*>/, 'blockquote'], + [/^\s*!\[/, 'image'], + [/^\s*\|/, 'table'], + [/^#{4,} /, 'heading below level 3'], + ]; + for (const [pattern, what] of unsupported) { + if (pattern.test(line)) { + throw new Error( + `CHANGELOG.md:${lineNumber} uses an unsupported construct (${what}): ${line.trim()}\n` + + 'Extend scripts/build-changelog.js to render it, or reword the entry.' + ); + } + } +} + +/** + * Parse CHANGELOG.md into an intro plus one entry per release. + * + * Blocks are `{ type: 'paragraph' | 'list' | 'group' }`. A `group` is a `###` + * subsection (Added / Changed / ...) holding its own blocks, which is how the + * changelog is actually shaped. + */ +function parseChangelog(source) { + const lines = toLf(source).split('\n'); + const intro = { blocks: [] }; + const releases = []; + + let release = null; + let group = null; + let paragraph = null; + let items = null; + + const sink = () => (group ? group.blocks : release ? release.blocks : intro.blocks); + const flushParagraph = () => { + if (!paragraph) return; + sink().push({ type: 'paragraph', text: paragraph.join(' ') }); + paragraph = null; + }; + const flushList = () => { + if (!items) return; + sink().push({ type: 'list', items }); + items = null; + }; + const flushBlocks = () => { + flushParagraph(); + flushList(); + }; + + lines.forEach((raw, index) => { + const lineNumber = index + 1; + const line = raw.replace(/\s+$/, ''); + + if (line === '') { + flushBlocks(); + return; + } + + rejectUnsupported(line, lineNumber); + + // The document title is the page's own

; the file's copy is dropped. + if (/^# /.test(line)) { + flushBlocks(); + return; + } + + const releaseHeading = line.match(/^## +(.*)$/); + if (releaseHeading) { + flushBlocks(); + group = null; + release = parseReleaseHeading(releaseHeading[1].trim()); + releases.push(release); + return; + } + + const groupHeading = line.match(/^### +(.*)$/); + if (groupHeading) { + flushBlocks(); + if (!release) { + throw new Error(`CHANGELOG.md:${lineNumber} has a "###" heading before any release heading`); + } + group = { type: 'group', name: groupHeading[1].trim(), blocks: [] }; + release.blocks.push(group); + return; + } + + const bullet = line.match(/^- +(.*)$/); + if (bullet) { + flushParagraph(); + if (!items) items = []; + items.push(bullet[1].trim()); + return; + } + + // An indented line continues the bullet above it (the changelog wraps + // long entries); anywhere else it is just a wrapped paragraph line. + const continuation = line.match(/^ {2,}(.*)$/); + if (continuation && items) { + items[items.length - 1] += ` ${continuation[1].trim()}`; + return; + } + + flushList(); + if (!paragraph) paragraph = []; + paragraph.push(line.trim()); + }); + + flushBlocks(); + + if (!releases.length) { + throw new Error('CHANGELOG.md has no "## " release headings'); + } + const ids = releases.map((r) => r.id); + const duplicate = ids.find((id, i) => ids.indexOf(id) !== i); + if (duplicate) { + throw new Error(`two releases in CHANGELOG.md resolve to the same anchor "#${duplicate}"`); + } + + return { intro, releases }; +} + +/* ---- Inline rendering -------------------------------------------------- */ + +function escapeHtml(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderLinkUrl(url) { + // The source is a repo file, but a scheme check keeps a stray `javascript:` + // out of the generated page instead of trusting review to catch it. + if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !/^https?:/i.test(url)) { + throw new Error(`unsupported link scheme in CHANGELOG.md: ${url}`); + } + return url; +} + +/** + * Curl the quotes so rendered entries match the typography of the + * hand-written pages, which use ’ and “ ” throughout. A quote opens when it + * follows whitespace or an opening bracket at the start of a run, and closes + * otherwise - the standard heuristic, and unambiguous for this content. + * + * `openable` is false when the segment follows a code span, so the `'s` in + * `` `vision.md` ``'s closes rather than opening. + */ +function curlQuotes(text, openable) { + let out = ''; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char !== '"' && char !== "'") { + out += char; + continue; + } + const prev = i === 0 ? (openable ? '' : 'x') : text[i - 1]; + const opens = prev === '' || /[\s([{]/.test(prev); + if (char === '"') out += opens ? '“' : '”'; + else out += opens ? '‘' : '’'; + } + return out; +} + +/** Prose only: inline code is split out before this runs. */ +function renderProse(text, openable) { + // Quotes are curled before escaping so " entities are never rewritten. + let out = escapeHtml(curlQuotes(text, openable)); + out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, label, url) => { + const href = renderLinkUrl(url); + const external = /^https?:/i.test(href); + const attrs = external ? ' target="_blank" rel="noopener noreferrer"' : ''; + return `${label}`; + }); + out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); + out = out.replace(/(?$1'); + return out; +} + +function renderInline(text) { + const parts = text.split(/(`[^`]*`)/); + let afterCode = false; + return parts + .map((part) => { + const isCode = part.length > 1 && part.startsWith('`') && part.endsWith('`'); + if (isCode) { + afterCode = true; + return `${escapeHtml(part.slice(1, -1))}`; + } + const prose = renderProse(part, !afterCode); + if (part !== '') afterCode = false; + return prose; + }) + .join(''); +} + +/* ---- Block rendering --------------------------------------------------- */ + +const formatDate = (iso, { short = false } = {}) => { + const [year, month, day] = iso.split('-').map(Number); + const name = MONTHS[month - 1]; + if (!name) throw new Error(`invalid date in CHANGELOG.md: ${iso}`); + return `${short ? name.slice(0, 3) : name} ${day}, ${year}`; +}; + +const indent = (depth) => ' '.repeat(depth); + +function renderBlocks(blocks, depth) { + const out = []; + for (const block of blocks) { + if (block.type === 'paragraph') { + out.push(`${indent(depth)}

${renderInline(block.text)}

`); + continue; + } + if (block.type === 'list') { + out.push(`${indent(depth)}
    `); + for (const item of block.items) { + out.push(`${indent(depth + 1)}
  • ${renderInline(item)}
  • `); + } + out.push(`${indent(depth)}
`); + continue; + } + // group + const kind = block.name.toLowerCase().replace(/[^a-z]+/g, '-'); + out.push(`${indent(depth)}
`); + out.push( + `${indent(depth + 1)}

` + + `${renderInline(block.name)}

` + ); + out.push(...renderBlocks(block.blocks, depth + 1)); + out.push(`${indent(depth)}
`); + } + return out; +} + +function renderReleases(releases, depth) { + const latest = releases.find((release) => !release.unreleased); + const out = []; + releases.forEach((release, index) => { + if (index > 0) out.push(''); + out.push(`${indent(depth)}
`); + out.push(`${indent(depth + 1)}
`); + out.push( + `${indent(depth + 2)}

${renderInline(release.version)}

` + ); + if (release.date) { + out.push( + `${indent(depth + 2)}` + ); + } + if (release.unreleased) { + out.push(`${indent(depth + 2)}In progress`); + } else if (release === latest) { + out.push(`${indent(depth + 2)}Latest`); + } + out.push(`${indent(depth + 1)}
`); + out.push(...renderBlocks(release.blocks, depth + 1)); + out.push(`${indent(depth)}
`); + }); + return out; +} + +function renderToc(releases, depth) { + return releases.map((release) => { + const label = renderInline(release.version); + // A dateless released version (1.0.0) simply has no date to show; only an + // "Unreleased" heading is labelled as still in progress. + const meta = release.date + ? formatDate(release.date, { short: true }) + : (release.unreleased ? 'in progress' : ''); + const date = meta ? `${meta}` : ''; + return `${indent(depth)}${label}${date}`; + }); +} + +function renderIntro(intro, depth) { + const paragraphs = intro.blocks.filter((block) => block.type === 'paragraph'); + return paragraphs.map((block, index) => { + const cls = index === 0 ? 'docs-lead' : 'docs-note docs-hero-note'; + return `${indent(depth)}

${renderInline(block.text)}

`; + }); +} + +/* ---- Page assembly ----------------------------------------------------- */ + +const REGIONS = ['intro', 'toc', 'releases']; + +/** Replace the body of one `` region. */ +function replaceRegion(page, name, lines) { + const start = ``; + const end = ``; + const pattern = new RegExp( + `([ \\t]*)${start}\\n[\\s\\S]*?[ \\t]*${end}` + ); + if (!pattern.test(page)) { + throw new Error(`site/changelog.html is missing the "${name}" generated region (${start} ... ${end})`); + } + return page.replace(pattern, (whole, lead) => { + const body = lines.length ? `${lines.join('\n')}\n` : ''; + return `${lead}${start}\n${body}${lead}${end}`; + }); +} + +/** Render the changelog into the page shell. Both arguments are LF text. */ +function renderPage(pageShell, changelogSource) { + const { intro, releases } = parseChangelog(changelogSource); + // Indent depths match the hand-written markup around each region. + let page = replaceRegion(pageShell, 'intro', renderIntro(intro, 5)); + page = replaceRegion(page, 'toc', renderToc(releases, 5)); + page = replaceRegion(page, 'releases', renderReleases(releases, 4)); + return page; +} + +/** Render the committed page from the committed changelog. */ +function build() { + const pageOnDisk = fs.readFileSync(PAGE_PATH, 'utf8'); + const changelog = fs.readFileSync(CHANGELOG_PATH, 'utf8'); + const expected = renderPage(toLf(pageOnDisk), toLf(changelog)); + return { pageOnDisk, expected, upToDate: toLf(pageOnDisk) === expected }; +} + +function main(argv) { + const check = argv.includes('--check'); + const { pageOnDisk, expected, upToDate } = build(); + + if (upToDate) { + process.stdout.write( + check + ? 'site/changelog.html is up to date with CHANGELOG.md\n' + : 'site/changelog.html already matches CHANGELOG.md - nothing to write\n' + ); + return 0; + } + + if (check) { + process.stderr.write( + 'site/changelog.html is out of date with CHANGELOG.md.\n' + + 'Run: npm run build:changelog\n' + ); + return 1; + } + + // Keep the file's existing line endings so a Windows checkout does not + // churn every line of the hand-written shell on every build. + const eol = pageOnDisk.includes('\r\n') ? '\r\n' : '\n'; + fs.writeFileSync(PAGE_PATH, expected.replace(/\n/g, eol)); + process.stdout.write('wrote site/changelog.html from CHANGELOG.md\n'); + return 0; +} + +if (require.main === module) { + try { + process.exit(main(process.argv.slice(2))); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); + } +} + +module.exports = { + CHANGELOG_PATH, + PAGE_PATH, + REGIONS, + build, + parseChangelog, + renderInline, + renderPage, + toLf, +}; diff --git a/site/changelog.html b/site/changelog.html new file mode 100644 index 0000000..cfaa56d --- /dev/null +++ b/site/changelog.html @@ -0,0 +1,269 @@ + + + + + + + + + + Changelog — opencode Fusion + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+

Changelog

+

What changed

+ +

Notable changes to this project. The version is the skill bundle’s version; it is recorded as bundleVersion in .fusion-install.json when you install, so an installed copy can be traced back to the release that applied it.

+

This project follows Semantic Versioning.

+ +

Rendered from CHANGELOG.md, which is canonical.

+
+ + +
+
+

Unreleased

+ In progress +
+
+

Added

+
    +
  • The docs site has a changelog page. site/changelog.html is generated from this file by scripts/build-changelog.js (npm run build:changelog) and committed, because GitHub Pages serves site/ verbatim and this file lives outside site/, so the page cannot fetch it at runtime. npm test re-renders and fails when the committed page has drifted, so this file stays the single source of truth. The renderer supports only the markdown used here and fails loudly on anything else, rather than printing literal markup on the site.
  • +
+
+
+

Changed

+
    +
  • The mobile nav now collapses below 940px instead of 900px. The new Changelog link makes the widest desktop nav row need about 930px, and between the old breakpoint and that width the right-hand header pill ran past the rail and gave every page a horizontal scroll. A test pins the breakpoint against the measured width.
  • +
+
+
+

Fixed

+
    +
  • The theme toggle’s moon rendered as a lopsided blob rather than a crescent. Its inner arc asked for radius 7 across endpoints 16.3 apart; per the SVG spec a radius too small to span its endpoints is scaled up until it fits, so the terminator became a semicircle mirroring the outer edge and the shape lost its taper. It also overflowed the top and right of its 24x24 viewBox. Replaced with a crescent whose arcs are geometrically consistent, on all three pages.
  • +
+
+
+ +
+
+

1.1.0

+ + Latest +
+
+

Added

+
    +
  • The skill bundle now has a version. install.js records bundleVersion in .fusion-install.json and prints it in the apply plan, and /fusion-status reports it, so an installed copy can be traced back to the release that applied it. Like installedAt, it records the most recent apply: a reapply that selects a subset of roles leaves the other managed files as an earlier bundle installed them.
  • +
  • Repository, homepage, issue-tracker, and keyword metadata in package.json.
  • +
  • The bash allowlist’s JS assumption is now documented. The shipped verification commands (npm test, npm run lint, npm run build, npx tsc --noEmit, npx vitest run) only exist in a Node toolchain, so on any other stack the main agent could not verify its own work and got denied on the command it should have been running. README now names the verification tools per stack (Python, Rust, Go, Make), advises an exact pattern over a trailing * because * matches the whole rest of the command and a broad "ruff check*" also permits the file-rewriting --fix and --add-noqa, spells out that a narrowing deny must follow the allow it narrows because matching is last-match-wins, and records which of the five entries each role actually ships - plan.md has no npm run build*, and reviewer.md has neither that nor npx tsc --noEmit*, while build.md also allows the read-only npm --version*. It also says why the shipped JS entries keep a trailing * despite that advice: npm test and npm run lint take project-specific arguments, so pinning them exactly would deny the run the user wants. Step 5 of the setup skill tells the installer to raise it, and both note that the prompts are global rather than per project, so a multi-stack user should add entries alongside the JS ones instead of replacing them. The git half of the allowlist is unchanged and needs no substitution. No entries were added to the bundled prompts: a shipped entry for a command the user’s project does not define cannot verify that project, and only broadens a security-sensitive list.
  • +
  • An advisory CI lane runs the live enforcement tests against the opencode 2.0 beta. FUSION_OPENCODE_BIN selects which binary the integration harness spawns and defaults to opencode, so the existing v1 lanes spawn the same executable, arguments, and working directory they did before. Only the v2 branch adapts: its run has no directory flag, so the project directory is passed as the child process working directory, --auto is required because v2 otherwise rejects every permission request, and --standalone because v2 otherwise reuses a shared background service that cannot start under the harness’s throwaway HOME. Explicit deny rules stay enforced, which is what the tests assert.
  • +
  • The enforcement suite now resolves the two tools v2 renamed (bash -> shell, task -> subagent, whose delegation argument moved from subagent_type to agent) so one set of assertions covers both binaries. Every other tool it names - read, edit, write, grep, glob - kept its v1 name under v2.
  • +
  • The suite refuses to pass vacuously. Each “denied tool is absent” assertion now also requires that an unrestricted agent is offered that tool, so an absence caused by a release dropping the tool is reported as inconclusive instead of green. This immediately caught apply_patch and list, which no tested release offers to anyone; they are now asserted defensively rather than counted as evidence of enforcement.
  • +
+
+
+

Changed

+
    +
  • Agent routing in build.md now gives each role a positive and a negative case (Delegate when / Don't delegate when) plus a rule of thumb, instead of one line per role. sidekick, design, research, and reviewer escalate when handed work that belongs to another role rather than returning partial work. research.md and reviewer.md gained a STATUS / FINDINGS / VERIFIED / GAPS report format, modeled on the STATUS / CHANGES / VERIFIED / GAPS block sidekick.md already used, and plan.md gained a plan format adapted from the five-part delegation spec. Prompt wording only - the mechanical guarantees still come from permission frontmatter.
  • +
  • plan.md now carries the bash-allowlist guidance build.md already had: do not chain commands, and prefer the tool workdir parameter over cd or flag-first forms. Plan hit both limits in live use and recovered by retrying, which cost a round trip each time.
  • +
  • Corrected what chaining actually does, in build.md, plan.md, reviewer.md, README, and site/docs.html. All of them said a chained line matches no pattern and is blocked outright. Probed against opencode 1.18.7 through the integration harness, the permission layer matches each command in the line separately and denies the call only if one of them fails: git status --short && git log -1 runs when both are allowlisted, while git status --short | findstr README is denied because the pipe consumer counts as its own command. The advice to run one command per call is unchanged - a denial then names the command responsible - but the stated reason was wrong. A contract test now pins the disproven wording out of all five surfaces, so the correction cannot rot back in one file at a time.
  • +
  • plan.md and reviewer.md now state that a denied command is a boundary rather than a puzzle: find an allowed command that answers the same question, or report which one you would need, instead of hunting for a variant that slips through. Only build.md said this before, and in live use a reviewer refused npm run test:integration went looking for a way around it.
  • +
  • The harness config now sets subagent_depth: 2, matching what the installer forces on every real install. Without it the suite exercised a nesting limit no Fusion user runs under, so a delegation regression could have passed.
  • +
  • The Limitations section now states what opencode 2.0 does and does not carry over. v2 translates v1-shaped configuration in memory, so the config and agent prompts are expected to load under opencode2 unconverted; V1 plugins do not run there, three top-level keys have no native equivalent, and subagents do not inherit a parent agent’s permissions.
  • +
  • vision.md’s clipboard instructions no longer assume Windows. The save-to-file hint now covers macOS and Linux alongside Win+Shift+S, and tells the agent to ask for a path rather than guess the user’s desktop environment.
  • +
+
+
+

Compatibility

+
    +
  • Manifests written before 1.1.0 have no bundleVersion. They are still valid manifest schema 2, and undo continues to accept them.
  • +
+
+
+ +
+
+

1.0.0

+
+

Initial release: the mechanically enforced main/sidekick split, the deterministic installer with reversible undo, five subscription profiles, the optional /fusion-setup and /fusion-status commands, the fusion-audit and fusion-claude plugins, and multi-OS CI with live enforcement tests.

+
+ + +
+

Independent project inspired by Cognition’s Devin Fusion. Not affiliated with or endorsed by Cognition, Devin, or OpenCode.

+

+ GitHub repository + · + Docs + · + Back to home +

+
+
+
+
+ + + + + + diff --git a/site/docs.html b/site/docs.html index 49548d3..764fb03 100644 --- a/site/docs.html +++ b/site/docs.html @@ -103,6 +103,7 @@
  • Home
  • OpenCode
  • Customize
  • +
  • Changelog
  • Get started @@ -120,8 +121,11 @@ aria-label="Toggle color theme" title="Toggle color theme" > +

    FAQ

    GitHub repository · + Changelog + · llms.txt · Back to home @@ -446,6 +452,7 @@

    FAQ