b5da51ea0a
* feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide The in-product MCP URLs omitted `tool_namespace=accounted`, and resolveMcpToolNamespace() falls back to the legacy `gnubok_` prefix when the param is absent. Every connection made from Settings therefore got `gnubok_*` tool names while the docs, the accounted-api skill, and claude-plugin/.mcp.json all reference `accounted_*`. - Add `tool_namespace=accounted` to the Claude.ai, Claude Code, and Claude Desktop snippets. - Move the Claude Desktop bridge from `npx gnubok-mcp` / `GNUBOK_API_KEY` to `npx -y accounted-mcp` / `ACCOUNTED_API_KEY`, and emit `ACCOUNTED_URL` so self-hosted and white-label instances get a config pointing at their own host. The `gnubok_sk_` key prefix is unchanged: it is wire format. - Surface the Claude Code plugin, the only path that configures the connection and the seven workflow commands in one step. - Rename the settings tab "API" to "API & MCP" and rewrite its intro: the MCP connection is what most users come here for, not API keys. - Link the step-by-step guide from the panel, locale-aware. Docs: add a Swedish /docs/api/anslut-claude alongside the English page (the docs site has no locale routing, so each language is its own URL), give both the Claude Code plugin path, and teach the export and freshness scripts about the new page so cross-repo drift is caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg * fix(settings,docs): Cursor is not Claude Code; use the accounted_ tool name Review follow-up on #2105. `claude mcp add` is a Claude Code command. Cursor does not read it, so the "Claude Code / Cursor" row and the docs sentence pointing Cursor users at that command were both wrong (the row predates this PR; the docs sentence did not). Cursor now gets its own row and its own `~/.cursor/mcp.json` snippet with the `url` field, in the panel and in both docs pages. Also `vat_close_check` -> `accounted_vat_close_check` in the reviewer test on both pages, matching the identifier used in the prompts section above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg * feat(settings,docs): one-click Connect to Claude, and cut the panel to one action Anthropic documents an install link for custom connectors: https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=NAME&connectorUrl=ENCODED (claude.com/docs/connectors/building/directory-vs-custom). It opens claude.ai with the connector name and URL prefilled; the user still reviews and confirms, and it grants nothing on its own. We were telling people to copy a URL and go paste it somewhere else instead. Settings panel, rendered and reviewed: - "Connect to Claude" button is now the only thing above the fold. Everything that needs a config file or a terminal (claude.ai manual paste, Claude Code, the plugin, Cursor) moved into one "Other clients" disclosure, and the API-key methods keep theirs. Four code blocks -> one button, 1057px -> 719px. - The connect group renders above the API-keys group. Connecting is why users open this tab; the tab's own intro says so. - Each entry inside the disclosures shows its instruction as visible text. They were `?` HelpPopovers, so the panel read as opaque code blobs with no instructions on screen. - Prose interpolates the brand's real casing, not the lowercased config key. Docs, both languages: Path A leads with the install link and drops from five manual steps to a link plus three short paragraphs, with the manual paste kept under a subheading. No raw HTML: the docs renderer has no rehype-raw, so <details> would have been silently dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg * fix(settings,docs): correct claude mcp add syntax and the SSR install link Review follow-up. Both findings verified before acting on them. `claude mcp add --help` gives `claude mcp add [options] <name> <commandOrUrl>`: the URL is positional and there is no `--url` flag, so the API-key snippet would have failed on a missing argument. Both commands now put `--transport http` before the name and pass the URL positionally, in the panel and in both docs pages. The panel is server-rendered before it hydrates and window.location has no server equivalent, so the install link was built from a relative mcpBase in the first paint. A click in that window would hand claude.ai a connectorUrl it cannot resolve. The origin now resolves after mount and the anchor carries no href until it is known, which also makes it unclickable rather than wrong. Verified: the SSR HTML contains no claude.ai href and no relative connectorUrl, post-hydration the href is absolute, and there are no hydration warnings. DECISIONS.md: code-span the `gnubok_*`/`accounted_*` wildcards so they stop rendering as emphasis, and drop the "no one-click deeplink" claim from the earlier entry rather than leave a false statement standing two lines above its own correction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
9.3 KiB
TypeScript
233 lines
9.3 KiB
TypeScript
/**
|
|
* Docs-freshness check: verifies that the public API docs on
|
|
* https://docs.accounted.se match what this repo would generate today.
|
|
*
|
|
* The docs site is served by the separate gnubok-website repo from snapshot
|
|
* files (`*.generated.ts` exported via scripts/export-docs-to-website.mts,
|
|
* plus hand-copied duplicates for changelog/versioning/webhooks/cookbook).
|
|
* Nothing re-syncs them automatically, so they drift whenever an endpoint,
|
|
* error code, or content page changes here. Every docs page has a raw
|
|
* markdown mirror route (`/<page>.md`), which makes the check exact: build
|
|
* each page from source, fetch the live mirror, diff.
|
|
*
|
|
* Run with `npx tsx scripts/check-docs-freshness.mts`.
|
|
* Exit codes: 0 = in sync, 1 = drift or missing pages, 2 = build/fetch failure.
|
|
* Used by the weekly `loop-docs-freshness` skill; keep the output format
|
|
* stable (the loop parses the FINGERPRINT lines for issue dedupe).
|
|
*/
|
|
import { createHash } from 'node:crypto'
|
|
import { createRequire } from 'node:module'
|
|
|
|
// `server-only` throws on import outside a Next.js server-component graph.
|
|
// The reference builder pulls it in transitively (lib/api/v1/load-routes ->
|
|
// every v1 route -> lib/init). Nothing here executes request-time code: it
|
|
// only reads exported markdown builders, so a no-op stub is the honest
|
|
// resolution. Same pattern as scripts/export-docs-to-website.mts.
|
|
const require = createRequire(import.meta.url)
|
|
const ModuleCtor = require('node:module') as {
|
|
_load: (request: string, ...rest: unknown[]) => unknown
|
|
}
|
|
const originalLoad = ModuleCtor._load
|
|
ModuleCtor._load = function (request: string, ...rest: unknown[]) {
|
|
if (request === 'server-only') return {}
|
|
return originalLoad.call(this, request, ...rest)
|
|
}
|
|
|
|
// Explicit types: these are read inside buildExpectedPages(), and TS cannot
|
|
// infer a closure-captured let assigned in a try block (implicit-any error
|
|
// under next build's type check; the export script gets away with the bare
|
|
// pattern only because it reads the variables at top level).
|
|
let errors!: typeof import('@/lib/docs/content/errors')
|
|
let reference!: typeof import('@/lib/docs/content/reference')
|
|
let connectClaude!: typeof import('@/lib/docs/content/connect-claude')
|
|
let anslutClaude!: typeof import('@/lib/docs/content/anslut-claude')
|
|
let changelog!: typeof import('@/lib/docs/content/changelog')
|
|
let versioning!: typeof import('@/lib/docs/content/versioning')
|
|
let webhooks!: typeof import('@/lib/docs/content/webhooks')
|
|
let cookbook!: typeof import('@/lib/docs/content/cookbook')
|
|
try {
|
|
errors = await import('@/lib/docs/content/errors')
|
|
reference = await import('@/lib/docs/content/reference')
|
|
connectClaude = await import('@/lib/docs/content/connect-claude')
|
|
anslutClaude = await import('@/lib/docs/content/anslut-claude')
|
|
changelog = await import('@/lib/docs/content/changelog')
|
|
versioning = await import('@/lib/docs/content/versioning')
|
|
webhooks = await import('@/lib/docs/content/webhooks')
|
|
cookbook = await import('@/lib/docs/content/cookbook')
|
|
} finally {
|
|
ModuleCtor._load = originalLoad
|
|
}
|
|
|
|
const DOCS_BASE = process.env.DOCS_BASE_URL ?? 'https://docs.accounted.se'
|
|
|
|
/**
|
|
* Same link-absolutising transform the export script applies: the website
|
|
* cannot serve root-relative /api/v1 links. Applied to BOTH sides before
|
|
* comparing so the check is insensitive to whether a hand-copied page was
|
|
* adapted or not.
|
|
*/
|
|
const APP_ORIGIN = 'https://app.gnubok.se'
|
|
function canonicalise(md: string): string {
|
|
return md
|
|
.replaceAll('](/api/v1/', `](${APP_ORIGIN}/api/v1/`)
|
|
.replaceAll('](/.well-known/', `](${APP_ORIGIN}/.well-known/`)
|
|
.replaceAll('\r\n', '\n')
|
|
.split('\n')
|
|
.map((l) => l.trimEnd())
|
|
.join('\n')
|
|
.trim()
|
|
}
|
|
|
|
function sha12(s: string): string {
|
|
return createHash('sha256').update(s).digest('hex').slice(0, 12)
|
|
}
|
|
|
|
interface PageCheck {
|
|
/** Path of the raw-markdown mirror on the docs site, no leading slash. */
|
|
path: string
|
|
expected: string
|
|
}
|
|
|
|
function buildExpectedPages(): PageCheck[] {
|
|
const buildErrorReferenceMd = errors.buildErrorReferenceMd
|
|
const buildResourcePages = reference.buildResourcePages
|
|
const buildReferenceOverviewMd = reference.buildReferenceOverviewMd
|
|
const { COOKBOOK, buildPlaceholderMd } = cookbook
|
|
if (!buildErrorReferenceMd || !buildResourcePages || !buildReferenceOverviewMd || !COOKBOOK) {
|
|
console.error('Missing builder exports; the content modules changed shape.')
|
|
process.exit(2)
|
|
}
|
|
|
|
const pages: PageCheck[] = [
|
|
{ path: 'reference.md', expected: buildReferenceOverviewMd() },
|
|
{ path: 'errors.md', expected: buildErrorReferenceMd() },
|
|
{ path: 'connect-claude.md', expected: connectClaude.CONNECT_CLAUDE_MD },
|
|
{ path: 'anslut-claude.md', expected: anslutClaude.ANSLUT_CLAUDE_MD },
|
|
{ path: 'changelog.md', expected: changelog.CHANGELOG_MD },
|
|
{ path: 'versioning.md', expected: versioning.VERSIONING_MD },
|
|
{ path: 'webhooks.md', expected: webhooks.WEBHOOKS_MD },
|
|
]
|
|
for (const page of buildResourcePages()) {
|
|
pages.push({ path: `reference/${page.slug}.md`, expected: page.markdown })
|
|
}
|
|
for (const entry of COOKBOOK) {
|
|
pages.push({
|
|
path: `cookbook/${entry.slug}.md`,
|
|
expected: entry.markdown ?? buildPlaceholderMd(entry),
|
|
})
|
|
}
|
|
return pages
|
|
}
|
|
|
|
type Verdict =
|
|
| { path: string; status: 'ok' }
|
|
| { path: string; status: 'missing'; fingerprint: string }
|
|
| {
|
|
path: string
|
|
status: 'drift'
|
|
fingerprint: string
|
|
firstDiffLine: number
|
|
expectedLine: string
|
|
liveLine: string
|
|
changedLines: number
|
|
}
|
|
| { path: string; status: 'fetch-error'; detail: string }
|
|
|
|
async function checkPage(page: PageCheck): Promise<Verdict> {
|
|
const url = `${DOCS_BASE}/${page.path}`
|
|
let res: Response
|
|
try {
|
|
res = await fetch(url, { redirect: 'follow' })
|
|
} catch (e) {
|
|
return { path: page.path, status: 'fetch-error', detail: String(e) }
|
|
}
|
|
if (res.status === 404) {
|
|
// A page this repo generates that the docs site does not serve at all:
|
|
// typically a new API resource whose generated snapshot + nav entry were
|
|
// never exported to the website repo.
|
|
return { path: page.path, status: 'missing', fingerprint: sha12(`missing:${page.path}`) }
|
|
}
|
|
if (!res.ok) {
|
|
return { path: page.path, status: 'fetch-error', detail: `HTTP ${res.status}` }
|
|
}
|
|
const live = canonicalise(await res.text())
|
|
const expected = canonicalise(page.expected)
|
|
if (live === expected) return { path: page.path, status: 'ok' }
|
|
|
|
const expectedLines = expected.split('\n')
|
|
const liveLines = live.split('\n')
|
|
let firstDiffLine = 0
|
|
while (
|
|
firstDiffLine < Math.min(expectedLines.length, liveLines.length) &&
|
|
expectedLines[firstDiffLine] === liveLines[firstDiffLine]
|
|
) {
|
|
firstDiffLine++
|
|
}
|
|
const maxLen = Math.max(expectedLines.length, liveLines.length)
|
|
let changedLines = Math.abs(expectedLines.length - liveLines.length)
|
|
for (let i = 0; i < Math.min(expectedLines.length, liveLines.length); i++) {
|
|
if (expectedLines[i] !== liveLines[i]) changedLines++
|
|
}
|
|
return {
|
|
path: page.path,
|
|
status: 'drift',
|
|
// Stable while the same pair of contents drifts; changes when either
|
|
// side changes, so a NEW drift after a fix reads as a new finding.
|
|
fingerprint: sha12(`${page.path}:${sha12(expected)}:${sha12(live)}`),
|
|
firstDiffLine: firstDiffLine + 1,
|
|
expectedLine: expectedLines[firstDiffLine] ?? '<end of file>',
|
|
liveLine: liveLines[firstDiffLine] ?? '<end of file>',
|
|
changedLines: Math.min(changedLines, maxLen),
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const pages = buildExpectedPages()
|
|
console.log(`Checking ${pages.length} docs pages against ${DOCS_BASE} ...\n`)
|
|
|
|
const verdicts: Verdict[] = []
|
|
const CONCURRENCY = 6
|
|
for (let i = 0; i < pages.length; i += CONCURRENCY) {
|
|
const batch = pages.slice(i, i + CONCURRENCY)
|
|
verdicts.push(...(await Promise.all(batch.map(checkPage))))
|
|
}
|
|
|
|
const ok = verdicts.filter((v) => v.status === 'ok')
|
|
const drift = verdicts.filter((v) => v.status === 'drift')
|
|
const missing = verdicts.filter((v) => v.status === 'missing')
|
|
const failed = verdicts.filter((v) => v.status === 'fetch-error')
|
|
|
|
for (const v of verdicts) {
|
|
if (v.status === 'ok') continue
|
|
if (v.status === 'missing') {
|
|
console.log(`MISSING ${v.path}`)
|
|
console.log(` page exists in erp-base but the docs site returns 404`)
|
|
console.log(` FINGERPRINT ${v.fingerprint}`)
|
|
} else if (v.status === 'drift') {
|
|
console.log(`DRIFT ${v.path}`)
|
|
console.log(` ~${v.changedLines} differing line(s), first at line ${v.firstDiffLine}:`)
|
|
console.log(` repo builds: ${v.expectedLine.slice(0, 160)}`)
|
|
console.log(` site serves: ${v.liveLine.slice(0, 160)}`)
|
|
console.log(` FINGERPRINT ${v.fingerprint}`)
|
|
} else {
|
|
console.log(`ERROR ${v.path}: ${v.detail}`)
|
|
}
|
|
console.log('')
|
|
}
|
|
|
|
console.log(
|
|
`Summary: ${ok.length} in sync, ${drift.length} drifted, ${missing.length} missing, ${failed.length} fetch errors.`,
|
|
)
|
|
|
|
if (failed.length > 0) process.exit(2)
|
|
if (drift.length > 0 || missing.length > 0) {
|
|
console.log('\nTo fix: run `npx tsx scripts/export-docs-to-website.mts`, then port any')
|
|
console.log('hand-copied pages (changelog/versioning/webhooks/cookbook) and nav entries')
|
|
console.log('in the gnubok-website repo, and deploy it. See loop-docs-freshness skill.')
|
|
process.exit(1)
|
|
}
|
|
console.log('Docs are up to date.')
|
|
}
|
|
|
|
await main()
|