Files
accounted/scripts/export-docs-to-website.mts
Jakob Wennberg b5da51ea0a feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide (#2105)
* 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>
2026-09-01 16:56:19 +02:00

122 lines
5.7 KiB
TypeScript

/**
* One-shot script that exports the registry-derived docs content (errors +
* reference) plus the static Connect-with-Claude page as TypeScript modules
* into the gnubok-website repo. Connect-with-Claude ships in both languages:
* the docs site has no locale routing, so each language is its own page.
*
* Run with `npx tsx scripts/export-docs-to-website.mts`. Re-run whenever
* structured-errors, the v1 endpoint registry, or connect-claude materially
* changes.
*/
import { writeFileSync, mkdirSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
// `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 -> lib/analytics/posthog-observability ->
// posthog-server), which broke this script the moment PostHog landed. Nothing
// here executes request-time code: it only reads exported markdown builders,
// so a no-op stub is the honest resolution.
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)
}
let errors, reference, connectClaude, anslutClaude
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')
} finally {
// Scope the stub to the imports that need it: leaving a global loader hook
// patched for the rest of the process would silently disarm the guard for
// anything imported later (compliance swarm, ISO 27001 A.8.28).
ModuleCtor._load = originalLoad
}
const buildErrorReferenceMd = errors.buildErrorReferenceMd ?? (errors as { default?: typeof errors }).default?.buildErrorReferenceMd
const buildResourcePages = reference.buildResourcePages ?? (reference as { default?: typeof reference }).default?.buildResourcePages
const buildReferenceOverviewMd = reference.buildReferenceOverviewMd ?? (reference as { default?: typeof reference }).default?.buildReferenceOverviewMd
if (!buildErrorReferenceMd || !buildResourcePages || !buildReferenceOverviewMd) {
console.error('Missing builder exports. Inspect:', {
errorsKeys: Object.keys(errors),
referenceKeys: Object.keys(reference),
})
process.exit(1)
}
const WEBSITE = resolve('/Users/jakobwennberg/gnubok-website')
/**
* The website is served from www.accounted.se while the API (and everything
* under /api/v1 and /.well-known) lives on app.gnubok.se — root-relative links
* to app-served resources would 404 on the website, so absolutise them.
*/
const APP_ORIGIN = 'https://app.gnubok.se'
function adaptForWebsite(md: string): string {
return md
.replaceAll('](/api/v1/', `](${APP_ORIGIN}/api/v1/`)
.replaceAll('](/.well-known/', `](${APP_ORIGIN}/.well-known/`)
}
function write(rel: string, content: string) {
const out = resolve(WEBSITE, rel)
mkdirSync(dirname(out), { recursive: true })
writeFileSync(out, content)
console.log(`wrote ${out} (${content.length} chars)`)
}
const errorsMd = adaptForWebsite(buildErrorReferenceMd())
write(
'lib/docs/content/errors.generated.ts',
`// AUTO-GENERATED from erp-base: do not hand-edit.\n// Regenerate via \`npx tsx scripts/export-docs-to-website.mts\` in erp-base.\nexport const ERRORS_MD = ${JSON.stringify(errorsMd)}\n`,
)
const refOverview = adaptForWebsite(buildReferenceOverviewMd())
const refPages = buildResourcePages()
const slugs = refPages.map((p: { slug: string }) => p.slug)
const pagesPayload = refPages.map((p: { slug: string; label: string; description: string; markdown: string }) => ({
slug: p.slug,
label: p.label,
description: p.description,
markdown: adaptForWebsite(p.markdown),
}))
write(
'lib/docs/content/reference.generated.ts',
`// AUTO-GENERATED from erp-base: do not hand-edit.\n// Regenerate via \`npx tsx scripts/export-docs-to-website.mts\` in erp-base.\n\nexport const REFERENCE_OVERVIEW_MD = ${JSON.stringify(refOverview)}\n\nexport interface ResourcePage {\n slug: string\n label: string\n description: string\n markdown: string\n}\n\nexport const RESOURCE_SLUGS: readonly string[] = ${JSON.stringify(
slugs,
)} as const\n\nexport const RESOURCE_PAGES: ResourcePage[] = ${JSON.stringify(pagesPayload, null, 2)}\n\nexport function findResourcePage(slug: string): ResourcePage | undefined {\n return RESOURCE_PAGES.find((p) => p.slug === slug)\n}\n`,
)
const connectClaudeMd = connectClaude.CONNECT_CLAUDE_MD && adaptForWebsite(connectClaude.CONNECT_CLAUDE_MD)
if (!connectClaudeMd) {
console.error('Missing CONNECT_CLAUDE_MD export. Inspect:', { connectClaudeKeys: Object.keys(connectClaude) })
process.exit(1)
}
write(
'lib/docs/content/connect-claude.generated.ts',
`// AUTO-GENERATED from erp-base: do not hand-edit.\n// Regenerate via \`npx tsx scripts/export-docs-to-website.mts\` in erp-base.\nexport const CONNECT_CLAUDE_MD = ${JSON.stringify(connectClaudeMd)}\n`,
)
const anslutClaudeMd = anslutClaude.ANSLUT_CLAUDE_MD && adaptForWebsite(anslutClaude.ANSLUT_CLAUDE_MD)
if (!anslutClaudeMd) {
console.error('Missing ANSLUT_CLAUDE_MD export. Inspect:', { anslutClaudeKeys: Object.keys(anslutClaude) })
process.exit(1)
}
write(
'lib/docs/content/anslut-claude.generated.ts',
`// AUTO-GENERATED from erp-base: do not hand-edit.\n// Regenerate via \`npx tsx scripts/export-docs-to-website.mts\` in erp-base.\nexport const ANSLUT_CLAUDE_MD = ${JSON.stringify(anslutClaudeMd)}\n`,
)
console.log('done.')