# Adding a Git Forge to Paseo Paseo's forge layer is a registry/manifest system. A forge is a runtime concern: shared protocol messages carry neutral/open facts, the server adapter owns behavior, and the app owns bundled presentation/runtime interpretation. The maintainer litmus test is the rule of thumb: > Adding a new forge means adding files in a new directory/module that implement > an interface, plus one entry in the centralized registry/manifest for that > package. ## The Three Registrations For forge `acme`, the expected end state is: 1. **Protocol manifest** - optional, only when the forge should be presented by shared manifest data. Add one `ForgeDefinition` to `packages/protocol/src/forge-manifest.ts`. 2. **Server adapter** - add `packages/server/src/services/acme-service.ts` implementing `ForgeService`, any adapter-owned fact types/guards/constants beside it, and one `defaultForgeRegistry` entry in `packages/server/src/services/forge-registry.ts`. 3. **App modules** - a forge splits into a pure logic half and a view half so logic consumers (URL builders, merge-capability, native checks, and the Node-based e2e harness) never pull the client rendering stack: - `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`, optional `facts` (schema, merge-capability, native-check fallbacks). No React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in `packages/app/src/git/forges/index.ts`. - `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component under `packages/app/src/components/icons/`), optional `brandColor`, optional `paneContributions`. Register in `CLIENT_FORGE_VIEW_MODULES` in `packages/app/src/git/forges/view.ts`. There should be no protocol typed-union arm, no central app icon/color/url/facts map, and no central server union of known forge facts. ## Protocol `forgeSpecific` on PR status is an open envelope: ```ts z.object({ forge: z.string() }).passthrough(); ``` The `forgeSpecific.forge` field is a **facts-family tag**, not the workspace brand id. Gitea, Forgejo, and Codeberg can all emit `forgeSpecific.forge === "gitea"` when they share the same facts shape, while top-level `status.forge` keeps the brand id (`"gitea"`, `"forgejo"`, `"codeberg"`). Protocol does not validate per-forge fact fields. Consumers that understand a facts family validate at runtime with their own schema/guard. Unknown or schema-mismatched facts render neutrally instead of failing the whole message parse. This is the version-skew win: an old client can receive facts from a newer forge and still show the PR/MR in a neutral state. Shipped GitHub compatibility stays separate: - `status.github` remains accepted for released peers. - The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts into `status.github` for older clients. - Do not add a compatibility shim unless a released peer (<= 0.1.102) can actually produce the state. ## Server The server-wide status type only promises: ```ts type ForgeSpecificStatusFacts = { forge: string } & Record; ``` Adapter-owned files define the typed shapes and guards, for example `github-facts.ts`, `gitlab-facts.ts`, and `gitea-facts.ts`. The adapter can keep strong internal types for construction and command guards, but shared server code must not grow a central list of forge fact arms. Register the adapter in `defaultForgeRegistry` with: - `createService` - `matchesHost` from manifest `cloudHosts` - `probeHost` when self-hosted/Enterprise detection is supported Cloud hosts in the manifest are a bounded public-host list, not a self-host allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge host that is either a known cloud host or one the CLI is already authenticated to. Adapter probes must not make anonymous HTTP requests to remote-derived hosts, and adapters must not route credentials to an unauthenticated host. ## App Each app forge splits into two modules so pure logic never imports the client rendering stack: `acme.ts` exports a `ClientForgeLogicModule`: - `id` - optional `urlGrammar` - optional `facts` registration (schema, merge-capability, native-check fallbacks) `acme.view.tsx` exports a `ClientForgeViewModule`: - `id` - `icon` - `brandColor` (`null` for neutral; GitHub intentionally uses `null`) - optional `paneContributions` Two registries live under `packages/app/src/git/forges/`: `CLIENT_FORGE_LOGIC_MODULES` (`index.ts`) drives URL grammar, merge-capability derivation, and native fallback checks; `CLIENT_FORGE_VIEW_MODULES` (`view.ts`) drives icon/color lookup and PR-pane contributions. Logic consumers must import the logic registry only — importing the view registry (or a `.view.tsx` module) from a logic path pulls react-native and breaks the Node-based e2e harness. Per-forge brand colors live on the module, not in `styles/theme.ts`. Use the Unistyles-safe pattern from `docs/unistyles.md`: no `useUnistyles()`. Brand icon call sites use `withUnistyles` and a `uniProps` mapping such as: ```ts (theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark }); ``` Facts modules use one source of truth: a Zod schema. Helpers like `defineForgeFacts`, `defineNativeFallbackCheck`, and `definePaneContribution` derive guards from `schema.safeParse` and re-parse before invoking typed derivers/renderers. That keeps typed derivers away from the open wire envelope. ## Checklist To add `acme`: 1. Add `acme` to `FORGE_DEFINITIONS` if the shared manifest should know its label, nouns, icon kind, sign-in CLI, or cloud hosts. 2. Add `acme-service.ts` implementing `ForgeService`. 3. Add `acme-facts.ts` beside the adapter if it reports native facts. 4. Add one `defaultForgeRegistry` entry. 5. Add `packages/app/src/git/forges/acme.ts` (logic) and `packages/app/src/git/forges/acme.view.tsx` (view). 6. Add one `CLIENT_FORGE_LOGIC_MODULES` entry (`index.ts`) and one `CLIENT_FORGE_VIEW_MODULES` entry (`view.ts`). 7. Add/update the icon component only if the client bundle should show a brand mark. 8. If the forge's CI/data model does not fit an existing required `ForgeService` field, widen the shared interface (plus the protocol schema and its guards) instead of faking a value — e.g. Gitea Actions runs carry no check-run id, so `GetCheckDetailsOptions.checkRunId` became optional with `workflowRunId` as the alternative address. Expect this step to touch `forge-service.ts`, `messages.ts`, and the call-site guards of the other adapters. Widening a shared field is not forge-local: it also affects the already-shipped forges/GitHub call sites and the capability-gated RPC (e.g. `forgeCheckDetails`), so verify every consumer rather than assuming the change only reaches the new adapter. 9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol checkout PR schema, app forge URL/presentation tests, app merge capability, and any PR-pane native data tests touched. Run `npm run typecheck` after each implementation slice. If protocol or client declarations are stale, run `npm run build:client`; if server/CLI declarations are stale, run `npm run build:server`. ## Gotchas - GitHub is a normal registry entry plus released compatibility shims. Keep all real shims tagged with `COMPAT(name)`. - Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the top-level brand is Forgejo or Codeberg. - Brand icons are bundled React components, so they cannot come from protocol manifest data. - Source URL grammars are app-side because blob/tree path syntax is forge-specific. If a forge has no grammar, omit the "Open on ..." source link rather than constructing a wrong URL. - GitLab pipeline status constants belong to the GitLab adapter/client module, not protocol.