From e835adeb16491563353eb1c007a28bde9cd6ff82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 14:24:19 -0700 Subject: [PATCH 01/13] Publish server settings schema with release versions - Generate the marketing schema and versioned copies during release version bumps - Move JSON Schema export helper into shared utilities - Update release smoke coverage and schema generation tests --- .github/workflows/release.yml | 10 +- apps/marketing/package.json | 5 +- .../schemas/server-settings.schema.json | 258 ++++++++++++++++++ .../server-settings/0.0.15.schema.json | 258 ++++++++++++++++++ .../src/git/Layers/ClaudeTextGeneration.ts | 2 +- .../src/git/Layers/CodexTextGeneration.ts | 2 +- apps/server/src/git/Utils.ts | 9 - docs/release.md | 2 + packages/shared/src/schemaJson.ts | 9 + scripts/build-server-settings-schema.ts | 12 + scripts/lib/server-settings-schema.test.ts | 53 ++++ scripts/lib/server-settings-schema.ts | 72 +++++ scripts/release-smoke.ts | 39 ++- .../update-release-package-versions.test.ts | 57 ++++ scripts/update-release-package-versions.ts | 6 +- 15 files changed, 772 insertions(+), 22 deletions(-) create mode 100644 apps/marketing/public/schemas/server-settings.schema.json create mode 100644 apps/marketing/public/schemas/server-settings/0.0.15.schema.json create mode 100644 scripts/build-server-settings-schema.ts create mode 100644 scripts/lib/server-settings-schema.test.ts create mode 100644 scripts/lib/server-settings-schema.ts create mode 100644 scripts/update-release-package-versions.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 504952e3aa..e91918d455 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,7 +126,7 @@ jobs: run: bun install --frozen-lockfile - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + run: bun scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - name: Build desktop artifact shell: bash @@ -248,7 +248,7 @@ jobs: run: bun install --frozen-lockfile - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + run: bun scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - name: Build CLI package run: bun run build --filter=@t3tools/web --filter=t3 @@ -348,7 +348,7 @@ jobs: name: Update version strings env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output + run: bun scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - name: Format package.json files if: steps.update_versions.outputs.changed == 'true' @@ -364,7 +364,7 @@ jobs: env: RELEASE_TAG: ${{ needs.preflight.outputs.tag }} run: | - if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock; then + if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json apps/marketing/public/schemas bun.lock; then echo "No version changes to commit." exit 0 fi @@ -372,6 +372,6 @@ jobs: git config user.name "${{ steps.app_bot.outputs.name }}" git config user.email "${{ steps.app_bot.outputs.email }}" - git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock + git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json apps/marketing/public/schemas bun.lock git commit -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 1763a00102..a641e7bb2a 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "astro dev", - "build": "astro build", + "build:schemas": "bun ../../scripts/build-server-settings-schema.ts", + "dev": "bun run build:schemas && astro dev", + "build": "bun run build:schemas && astro build", "preview": "astro preview", "typecheck": "astro check" }, diff --git a/apps/marketing/public/schemas/server-settings.schema.json b/apps/marketing/public/schemas/server-settings.schema.json new file mode 100644 index 0000000000..7726f42718 --- /dev/null +++ b/apps/marketing/public/schemas/server-settings.schema.json @@ -0,0 +1,258 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code Server Settings", + "description": "JSON Schema for the server-authoritative settings.json file consumed by T3 Code.", + "type": "object", + "properties": { + "enableAssistantStreaming": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "type": "string", + "enum": ["local", "worktree"] + }, + { + "type": "null" + } + ] + }, + "textGenerationModelSelection": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["codex"] + }, + "model": { + "type": "string" + }, + "options": { + "type": "object", + "properties": { + "reasoningEffort": { + "anyOf": [ + { + "type": "string", + "enum": ["xhigh", "high", "medium", "low"] + }, + { + "type": "null" + } + ] + }, + "fastMode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["claudeAgent"] + }, + "model": { + "type": "string" + }, + "options": { + "type": "object", + "properties": { + "thinking": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "effort": { + "anyOf": [ + { + "type": "string", + "enum": ["low", "medium", "high", "max", "ultrathink"] + }, + { + "type": "null" + } + ] + }, + "fastMode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "contextWindow": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "additionalProperties": false + } + ] + }, + { + "type": "null" + } + ] + }, + "providers": { + "anyOf": [ + { + "type": "object", + "properties": { + "codex": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "binaryPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "homePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "customModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "claudeAgent": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "binaryPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "customModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false +} diff --git a/apps/marketing/public/schemas/server-settings/0.0.15.schema.json b/apps/marketing/public/schemas/server-settings/0.0.15.schema.json new file mode 100644 index 0000000000..7726f42718 --- /dev/null +++ b/apps/marketing/public/schemas/server-settings/0.0.15.schema.json @@ -0,0 +1,258 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code Server Settings", + "description": "JSON Schema for the server-authoritative settings.json file consumed by T3 Code.", + "type": "object", + "properties": { + "enableAssistantStreaming": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "type": "string", + "enum": ["local", "worktree"] + }, + { + "type": "null" + } + ] + }, + "textGenerationModelSelection": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["codex"] + }, + "model": { + "type": "string" + }, + "options": { + "type": "object", + "properties": { + "reasoningEffort": { + "anyOf": [ + { + "type": "string", + "enum": ["xhigh", "high", "medium", "low"] + }, + { + "type": "null" + } + ] + }, + "fastMode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["claudeAgent"] + }, + "model": { + "type": "string" + }, + "options": { + "type": "object", + "properties": { + "thinking": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "effort": { + "anyOf": [ + { + "type": "string", + "enum": ["low", "medium", "high", "max", "ultrathink"] + }, + { + "type": "null" + } + ] + }, + "fastMode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "contextWindow": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "additionalProperties": false + } + ] + }, + { + "type": "null" + } + ] + }, + "providers": { + "anyOf": [ + { + "type": "object", + "properties": { + "codex": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "binaryPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "homePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "customModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "claudeAgent": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "binaryPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "customModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false +} diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 19ed40e65b..78baf26444 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -13,6 +13,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; import { resolveApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { toJsonSchemaObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "../Errors.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; @@ -27,7 +28,6 @@ import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, - toJsonSchemaObject, } from "../Utils.ts"; import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; import { ServerSettingsService } from "../../serverSettings.ts"; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 8f0556ee34..7d62015812 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -5,6 +5,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CodexModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { toJsonSchemaObject } from "@t3tools/shared/schemaJson"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -26,7 +27,6 @@ import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, - toJsonSchemaObject, } from "../Utils.ts"; import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 8f0321fd52..1f026abf38 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -14,15 +14,6 @@ export function isGitRepository(cwd: string): boolean { return existsSync(join(cwd, ".git")); } -/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ -export function toJsonSchemaObject(schema: Schema.Top): unknown { - const document = Schema.toJsonSchemaDocument(schema); - if (document.definitions && Object.keys(document.definitions).length > 0) { - return { ...document.schema, $defs: document.definitions }; - } - return document.schema; -} - /** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ export function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; diff --git a/docs/release.md b/docs/release.md index 4aec150f33..526f7c931f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -17,6 +17,8 @@ This document covers how to run desktop releases from one tag, first without sig - Includes Electron auto-update metadata (for example `latest*.yml` and `*.blockmap`) in release assets. - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing. - Signing is optional and auto-detected per platform from secrets. +- Refreshes the marketing-site JSON Schema for `settings.json` at `/schemas/server-settings.schema.json`. +- Commits an immutable versioned JSON Schema copy at `/schemas/server-settings/.schema.json`. ## Desktop auto-update notes diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 9e38b1f8b8..fb556172f5 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -43,6 +43,15 @@ export const formatSchemaError = (cause: Cause.Cause) => { : Cause.pretty(cause); }; +/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ +export const toJsonSchemaObject = (schema: Schema.Top): unknown => { + const document = Schema.toJsonSchemaDocument(schema); + if (document.definitions && Object.keys(document.definitions).length > 0) { + return { ...document.schema, $defs: document.definitions }; + } + return document.schema; +}; + /** * A `Getter` that parses a lenient JSON string (tolerating trailing commas * and JS-style comments) into an unknown value. diff --git a/scripts/build-server-settings-schema.ts b/scripts/build-server-settings-schema.ts new file mode 100644 index 0000000000..243beb2c3b --- /dev/null +++ b/scripts/build-server-settings-schema.ts @@ -0,0 +1,12 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + SERVER_SETTINGS_SCHEMA_RELATIVE_PATH, + writeServerSettingsJsonSchemas, +} from "./lib/server-settings-schema.ts"; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +writeServerSettingsJsonSchemas({ rootDir }); + +console.log(`Wrote ${SERVER_SETTINGS_SCHEMA_RELATIVE_PATH}`); diff --git a/scripts/lib/server-settings-schema.test.ts b/scripts/lib/server-settings-schema.test.ts new file mode 100644 index 0000000000..0015aa9a08 --- /dev/null +++ b/scripts/lib/server-settings-schema.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +import { describe, expect, it } from "vitest"; + +import { + buildServerSettingsJsonSchema, + getVersionedServerSettingsSchemaRelativePath, + SERVER_SETTINGS_SCHEMA_RELATIVE_PATH, + writeServerSettingsJsonSchemas, +} from "./server-settings-schema"; + +describe("buildServerSettingsJsonSchema", () => { + it("builds a JSON schema document for settings.json", () => { + const schema = buildServerSettingsJsonSchema(); + + expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.title).toBe("T3 Code Server Settings"); + expect(schema.type).toBe("object"); + expect(schema.additionalProperties).toBe(false); + expect(schema.properties).toMatchObject({ + enableAssistantStreaming: expect.any(Object), + defaultThreadEnvMode: expect.any(Object), + textGenerationModelSelection: expect.any(Object), + providers: expect.any(Object), + }); + }); + + it("writes latest and versioned schema files", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-server-settings-schema-")); + + try { + const result = writeServerSettingsJsonSchemas({ rootDir, version: "1.2.3" }); + expect(result.changed).toBe(true); + + const latestSchema = JSON.parse( + readFileSync(resolve(rootDir, SERVER_SETTINGS_SCHEMA_RELATIVE_PATH), "utf8"), + ) as Record; + const versionedSchema = JSON.parse( + readFileSync( + resolve(rootDir, getVersionedServerSettingsSchemaRelativePath("1.2.3")), + "utf8", + ), + ) as Record; + + expect(latestSchema).toEqual(versionedSchema); + expect(latestSchema.title).toBe("T3 Code Server Settings"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/lib/server-settings-schema.ts b/scripts/lib/server-settings-schema.ts new file mode 100644 index 0000000000..096135c38a --- /dev/null +++ b/scripts/lib/server-settings-schema.ts @@ -0,0 +1,72 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; + +import { ServerSettings } from "@t3tools/contracts/settings"; +import { toJsonSchemaObject } from "@t3tools/shared/schemaJson"; + +const JSON_SCHEMA_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"; + +export const SERVER_SETTINGS_SCHEMA_RELATIVE_PATH = + "apps/marketing/public/schemas/server-settings.schema.json"; +export const SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH = + "apps/marketing/public/schemas/server-settings"; + +export const getVersionedServerSettingsSchemaRelativePath = (version: string) => + `${SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.schema.json`; + +export function buildServerSettingsJsonSchema(): Record { + const schema = toJsonSchemaObject(ServerSettings); + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error("ServerSettings JSON schema must be an object schema."); + } + + return { + $schema: JSON_SCHEMA_DRAFT_2020_12, + title: "T3 Code Server Settings", + description: "JSON Schema for the server-authoritative settings.json file consumed by T3 Code.", + ...schema, + }; +} + +function writeJsonFileIfChanged(filePath: string, document: Record): boolean { + const nextContent = `${JSON.stringify(document, null, 2)}\n`; + const previousContent = (() => { + try { + return readFileSync(filePath, "utf8"); + } catch { + return null; + } + })(); + + if (previousContent === nextContent) { + return false; + } + + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, nextContent); + return true; +} + +export function writeServerSettingsJsonSchemas(options?: { + readonly rootDir?: string; + readonly version?: string; +}): { + readonly changed: boolean; +} { + const rootDir = resolve(options?.rootDir ?? process.cwd()); + const latestSchema = buildServerSettingsJsonSchema(); + let changed = writeJsonFileIfChanged( + resolve(rootDir, SERVER_SETTINGS_SCHEMA_RELATIVE_PATH), + latestSchema, + ); + + if (options?.version) { + changed = + writeJsonFileIfChanged( + resolve(rootDir, getVersionedServerSettingsSchemaRelativePath(options.version)), + latestSchema, + ) || changed; + } + + return { changed }; +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bf9d9f5c6a..6b24a4f2ad 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -81,7 +89,7 @@ try { copyWorkspaceManifestFixture(tempRoot); execFileSync( - process.execPath, + "bun", [ resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", @@ -106,9 +114,34 @@ try { "Expected bun.lock to contain the smoke version.", ); + const latestSchemaPath = resolve( + tempRoot, + "apps/marketing/public/schemas/server-settings.schema.json", + ); + const versionedSchemaPath = resolve( + tempRoot, + "apps/marketing/public/schemas/server-settings/9.9.9-smoke.0.schema.json", + ); + if (!existsSync(latestSchemaPath)) { + throw new Error( + "Expected release version alignment to generate the latest server settings schema.", + ); + } + if (!existsSync(versionedSchemaPath)) { + throw new Error( + "Expected release version alignment to generate the versioned server settings schema.", + ); + } + const versionedSchema = readFileSync(versionedSchemaPath, "utf8"); + assertContains( + versionedSchema, + '"title": "T3 Code Server Settings"', + "Expected versioned server settings schema to contain schema metadata.", + ); + const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); execFileSync( - process.execPath, + "bun", [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], { cwd: repoRoot, diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts new file mode 100644 index 0000000000..29332f1fc2 --- /dev/null +++ b/scripts/update-release-package-versions.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + releasePackageFiles, + updateReleasePackageVersions, +} from "./update-release-package-versions"; + +describe("updateReleasePackageVersions", () => { + it("updates package versions and writes latest plus versioned server settings schemas", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-release-version-bump-")); + + try { + for (const relativePath of releasePackageFiles) { + const filePath = resolve(rootDir, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync( + filePath, + `${JSON.stringify({ name: relativePath, version: "0.0.0" }, null, 2)}\n`, + ); + } + + const result = updateReleasePackageVersions("1.2.3", { rootDir }); + expect(result.changed).toBe(true); + + for (const relativePath of releasePackageFiles) { + const packageJson = JSON.parse(readFileSync(resolve(rootDir, relativePath), "utf8")) as { + version?: string; + }; + expect(packageJson.version).toBe("1.2.3"); + } + + expect( + JSON.parse( + readFileSync( + resolve(rootDir, "apps/marketing/public/schemas/server-settings.schema.json"), + "utf8", + ), + ), + ).toMatchObject({ title: "T3 Code Server Settings" }); + + expect( + JSON.parse( + readFileSync( + resolve(rootDir, "apps/marketing/public/schemas/server-settings/1.2.3.schema.json"), + "utf8", + ), + ), + ).toMatchObject({ title: "T3 Code Server Settings" }); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index b860b85e8e..a812f4f17f 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -2,6 +2,8 @@ import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { writeServerSettingsJsonSchemas } from "./lib/server-settings-schema.ts"; + export const releasePackageFiles = [ "apps/server/package.json", "apps/desktop/package.json", @@ -37,6 +39,8 @@ export function updateReleasePackageVersions( changed = true; } + changed = writeServerSettingsJsonSchemas({ rootDir, version }).changed || changed; + return { changed }; } @@ -99,7 +103,7 @@ if (isMain) { ); if (!changed) { - console.log("All package.json versions already match release version."); + console.log("All release version artifacts already match the requested version."); } if (writeGithubOutput) { From 805c68dd0e67e2af03058a4027bb76ee5b1f5d78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 14:33:33 -0700 Subject: [PATCH 02/13] Use Node executable for release smoke manifest merge - Run the manifest merge script with `process.execPath` instead of `bun` - Keeps the smoke test aligned with the current Node runtime --- scripts/release-smoke.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 6b24a4f2ad..704ef3c8aa 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -141,7 +141,7 @@ try { const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); execFileSync( - "bun", + process.execPath, [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], { cwd: repoRoot, From f1793ad2eb7ce8ea3485e4521bc8119379a177a2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 14:46:42 -0700 Subject: [PATCH 03/13] Generate and preserve keybindings schemas - Add marketing JSON Schema artifacts for keybindings - Reuse shared JSON schema writer for server settings and keybindings - Preserve top-level `$schema` when rewriting settings --- apps/marketing/package.json | 5 +- .../public/schemas/keybindings.schema.json | 63 ++++++++++++++++ .../schemas/keybindings/0.0.15.schema.json | 63 ++++++++++++++++ apps/server/src/keybindings.ts | 16 +--- apps/server/src/serverSettings.test.ts | 46 ++++++++++++ apps/server/src/serverSettings.ts | 33 +++++++- docs/release.md | 4 +- packages/shared/src/schemaJson.test.ts | 33 ++++++++ packages/shared/src/schemaJson.ts | 14 ++++ ...ttings-schema.ts => build-json-schemas.ts} | 7 ++ scripts/lib/json-schema.ts | 75 +++++++++++++++++++ scripts/lib/keybindings-schema.test.ts | 50 +++++++++++++ scripts/lib/keybindings-schema.ts | 32 ++++++++ scripts/lib/server-settings-schema.ts | 60 +++------------ scripts/release-smoke.ts | 24 ++++++ .../update-release-package-versions.test.ts | 20 ++++- scripts/update-release-package-versions.ts | 2 + 17 files changed, 476 insertions(+), 71 deletions(-) create mode 100644 apps/marketing/public/schemas/keybindings.schema.json create mode 100644 apps/marketing/public/schemas/keybindings/0.0.15.schema.json create mode 100644 packages/shared/src/schemaJson.test.ts rename scripts/{build-server-settings-schema.ts => build-json-schemas.ts} (64%) create mode 100644 scripts/lib/json-schema.ts create mode 100644 scripts/lib/keybindings-schema.test.ts create mode 100644 scripts/lib/keybindings-schema.ts diff --git a/apps/marketing/package.json b/apps/marketing/package.json index a641e7bb2a..1763a00102 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -4,9 +4,8 @@ "private": true, "type": "module", "scripts": { - "build:schemas": "bun ../../scripts/build-server-settings-schema.ts", - "dev": "bun run build:schemas && astro dev", - "build": "bun run build:schemas && astro build", + "dev": "astro dev", + "build": "astro build", "preview": "astro preview", "typecheck": "astro check" }, diff --git a/apps/marketing/public/schemas/keybindings.schema.json b/apps/marketing/public/schemas/keybindings.schema.json new file mode 100644 index 0000000000..605380eb49 --- /dev/null +++ b/apps/marketing/public/schemas/keybindings.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code Keybindings", + "description": "JSON Schema for the keybindings.json file consumed by T3 Code.", + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "terminal.toggle", + "terminal.split", + "terminal.new", + "terminal.close", + "diff.toggle", + "chat.new", + "chat.newLocal", + "editor.openFavorite", + "thread.previous", + "thread.next", + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9" + ] + }, + { + "type": "string", + "pattern": "^script\\.[\\s\\S]*?\\.run$" + } + ] + }, + "when": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["key", "command"], + "additionalProperties": false + }, + "allOf": [ + { + "maxItems": 256 + } + ] +} diff --git a/apps/marketing/public/schemas/keybindings/0.0.15.schema.json b/apps/marketing/public/schemas/keybindings/0.0.15.schema.json new file mode 100644 index 0000000000..605380eb49 --- /dev/null +++ b/apps/marketing/public/schemas/keybindings/0.0.15.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code Keybindings", + "description": "JSON Schema for the keybindings.json file consumed by T3 Code.", + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "terminal.toggle", + "terminal.split", + "terminal.new", + "terminal.close", + "diff.toggle", + "chat.new", + "chat.newLocal", + "editor.openFavorite", + "thread.previous", + "thread.next", + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9" + ] + }, + { + "type": "string", + "pattern": "^script\\.[\\s\\S]*?\\.run$" + } + ] + }, + "when": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["key", "command"], + "additionalProperties": false + }, + "allOf": [ + { + "maxItems": 256 + } + ] +} diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 176e0300ad..a3d73b459b 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -34,7 +34,6 @@ import { Predicate, PubSub, Schema, - SchemaGetter, SchemaIssue, SchemaTransformation, Ref, @@ -44,7 +43,7 @@ import { } from "effect"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { encodePrettyJsonEffect, fromLenientJson } from "@t3tools/shared/schemaJson"; export class KeybindingsConfigError extends Schema.TaggedErrorClass()( "KeybindingsConfigParseError", @@ -418,16 +417,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string { const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); -const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); -const PrettyJsonString = SchemaGetter.parseJson().compose( - SchemaGetter.stringifyJson({ space: 2 }), -); -const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe( - Schema.encode({ - decode: PrettyJsonString, - encode: PrettyJsonString, - }), -); +const encodeKeybindingsConfigPrettyJson = encodePrettyJsonEffect(KeybindingsConfig); export interface KeybindingsConfigState { readonly keybindings: ResolvedKeybindingsConfig; @@ -676,7 +666,7 @@ const makeKeybindings = Effect.gen(function* () { const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; - return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( + return encodeKeybindingsConfigPrettyJson(rules).pipe( Effect.map((encoded) => `${encoded}\n`), Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index c0aec009a0..232286aa14 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -216,4 +216,50 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("preserves a top-level $schema declaration when rewriting settings", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + `${JSON.stringify( + { + $schema: "https://t3.chat/schemas/server-settings/0.0.15.schema.json", + providers: { + codex: { + binaryPath: "/usr/local/bin/codex", + }, + }, + }, + null, + 2, + )}\n`, + ); + + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.start; + yield* serverSettings.updateSettings({ + providers: { + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + }, + }, + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.deepEqual(JSON.parse(raw), { + $schema: "https://t3.chat/schemas/server-settings/0.0.15.schema.json", + providers: { + codex: { + binaryPath: "/usr/local/bin/codex", + }, + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + }, + }, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a5b4345d50..06e38e2465 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -39,7 +39,7 @@ import { import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { encodePrettyJsonEffect, fromLenientJson } from "@t3tools/shared/schemaJson"; export class ServerSettingsError extends Schema.TaggedErrorClass()( "ServerSettingsError", @@ -101,6 +101,9 @@ export class ServerSettingsService extends ServiceMap.Service< } const ServerSettingsJson = fromLenientJson(ServerSettings); +const UnknownJson = fromLenientJson(Schema.Unknown); +const encodeServerSettingsDocument = encodePrettyJsonEffect(Schema.Unknown); +const SETTINGS_SCHEMA_DECLARATION_KEY = "$schema"; const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; @@ -177,6 +180,7 @@ const makeServerSettings = Effect.gen(function* () { const changesPubSub = yield* PubSub.unbounded(); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make(); + const schemaDeclarationRef = yield* Ref.make(undefined); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); @@ -207,10 +211,25 @@ const makeServerSettings = Effect.gen(function* () { const loadSettingsFromDisk = Effect.gen(function* () { if (!(yield* readConfigExists)) { + yield* Ref.set(schemaDeclarationRef, undefined); return DEFAULT_SERVER_SETTINGS; } const raw = yield* readRawConfig; + const rawJson = Schema.decodeUnknownExit(UnknownJson)(raw); + if (rawJson._tag === "Success") { + const value = rawJson.value; + const valueRecord = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; + const schemaDeclaration = + valueRecord && typeof valueRecord[SETTINGS_SCHEMA_DECLARATION_KEY] === "string" + ? valueRecord[SETTINGS_SCHEMA_DECLARATION_KEY] + : undefined; + yield* Ref.set(schemaDeclarationRef, schemaDeclaration); + } + const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); if (decoded._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { @@ -232,7 +251,17 @@ const makeServerSettings = Effect.gen(function* () { const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; - return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( + return Ref.get(schemaDeclarationRef).pipe( + Effect.map((schemaDeclaration) => + schemaDeclaration + ? { + [SETTINGS_SCHEMA_DECLARATION_KEY]: schemaDeclaration, + ...sparseSettings, + } + : sparseSettings, + ), + Effect.flatMap(encodeServerSettingsDocument), + Effect.map((encoded) => `${encoded}\n`), Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), Effect.flatMap(() => fs.rename(tempPath, settingsPath)), diff --git a/docs/release.md b/docs/release.md index 526f7c931f..cd890c2cf0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -17,8 +17,8 @@ This document covers how to run desktop releases from one tag, first without sig - Includes Electron auto-update metadata (for example `latest*.yml` and `*.blockmap`) in release assets. - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing. - Signing is optional and auto-detected per platform from secrets. -- Refreshes the marketing-site JSON Schema for `settings.json` at `/schemas/server-settings.schema.json`. -- Commits an immutable versioned JSON Schema copy at `/schemas/server-settings/.schema.json`. +- Refreshes the marketing-site JSON Schemas for `settings.json` and `keybindings.json` at `/schemas/server-settings.schema.json` and `/schemas/keybindings.schema.json`. +- Commits immutable versioned JSON Schema copies at `/schemas/server-settings/.schema.json` and `/schemas/keybindings/.schema.json`. ## Desktop auto-update notes diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts new file mode 100644 index 0000000000..fba4cf3d97 --- /dev/null +++ b/packages/shared/src/schemaJson.test.ts @@ -0,0 +1,33 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { encodePrettyJsonEffect } from "./schemaJson"; + +it.effect("encodePrettyJsonEffect writes indented JSON", () => + Effect.gen(function* () { + const encodePrettyJson = encodePrettyJsonEffect( + Schema.Struct({ + provider: Schema.String, + options: Schema.Struct({ + enabled: Schema.Boolean, + }), + }), + ); + + const encoded = yield* encodePrettyJson({ + provider: "codex", + options: { + enabled: true, + }, + }); + + assert.strictEqual( + encoded, + `{ + "provider": "codex", + "options": { + "enabled": true + } +}`, + ); + }), +); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index fb556172f5..010c4eccf6 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -36,6 +36,20 @@ export const decodeUnknownJsonResult = ().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); + +export const encodePrettyJsonEffect = (schema: S) => + Schema.encodeEffect( + Schema.fromJsonString(schema).pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), + ), + ); + export const formatSchemaError = (cause: Cause.Cause) => { const squashed = Cause.squash(cause); return Schema.isSchemaError(squashed) diff --git a/scripts/build-server-settings-schema.ts b/scripts/build-json-schemas.ts similarity index 64% rename from scripts/build-server-settings-schema.ts rename to scripts/build-json-schemas.ts index 243beb2c3b..cf667ab68f 100644 --- a/scripts/build-server-settings-schema.ts +++ b/scripts/build-json-schemas.ts @@ -1,12 +1,19 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + KEYBINDINGS_SCHEMA_RELATIVE_PATH, + writeKeybindingsJsonSchemas, +} from "./lib/keybindings-schema.ts"; import { SERVER_SETTINGS_SCHEMA_RELATIVE_PATH, writeServerSettingsJsonSchemas, } from "./lib/server-settings-schema.ts"; const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + writeServerSettingsJsonSchemas({ rootDir }); +writeKeybindingsJsonSchemas({ rootDir }); console.log(`Wrote ${SERVER_SETTINGS_SCHEMA_RELATIVE_PATH}`); +console.log(`Wrote ${KEYBINDINGS_SCHEMA_RELATIVE_PATH}`); diff --git a/scripts/lib/json-schema.ts b/scripts/lib/json-schema.ts new file mode 100644 index 0000000000..53fa5408ac --- /dev/null +++ b/scripts/lib/json-schema.ts @@ -0,0 +1,75 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { type Schema } from "effect"; + +import { toJsonSchemaObject } from "@t3tools/shared/schemaJson"; + +const JSON_SCHEMA_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"; + +export function buildJsonSchemaDocument( + schema: Schema.Top, + options: { + readonly title: string; + readonly description: string; + }, +): Record { + const jsonSchema = toJsonSchemaObject(schema); + if (!jsonSchema || typeof jsonSchema !== "object" || Array.isArray(jsonSchema)) { + throw new Error( + `${options.title} JSON schema must be an object or array JSON schema document.`, + ); + } + + return { + $schema: JSON_SCHEMA_DRAFT_2020_12, + title: options.title, + description: options.description, + ...jsonSchema, + }; +} + +function writeJsonFileIfChanged(filePath: string, document: Record): boolean { + const nextContent = `${JSON.stringify(document, null, 2)}\n`; + const previousContent = (() => { + try { + return readFileSync(filePath, "utf8"); + } catch { + return null; + } + })(); + + if (previousContent === nextContent) { + return false; + } + + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, nextContent); + return true; +} + +export function writeJsonSchemaArtifacts(options: { + readonly rootDir?: string; + readonly version?: string; + readonly latestRelativePath: string; + readonly getVersionedRelativePath: (version: string) => string; + readonly document: Record; +}): { + readonly changed: boolean; +} { + const rootDir = resolve(options.rootDir ?? process.cwd()); + let changed = writeJsonFileIfChanged( + resolve(rootDir, options.latestRelativePath), + options.document, + ); + + if (options.version) { + changed = + writeJsonFileIfChanged( + resolve(rootDir, options.getVersionedRelativePath(options.version)), + options.document, + ) || changed; + } + + return { changed }; +} diff --git a/scripts/lib/keybindings-schema.test.ts b/scripts/lib/keybindings-schema.test.ts new file mode 100644 index 0000000000..cf7be3d475 --- /dev/null +++ b/scripts/lib/keybindings-schema.test.ts @@ -0,0 +1,50 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +import { describe, expect, it } from "vitest"; + +import { + buildKeybindingsJsonSchema, + getVersionedKeybindingsSchemaRelativePath, + KEYBINDINGS_SCHEMA_RELATIVE_PATH, + writeKeybindingsJsonSchemas, +} from "./keybindings-schema"; + +describe("buildKeybindingsJsonSchema", () => { + it("builds a JSON schema document for keybindings.json", () => { + const schema = buildKeybindingsJsonSchema(); + + expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.title).toBe("T3 Code Keybindings"); + expect(schema.type).toBe("array"); + expect(schema.items).toMatchObject({ + type: "object", + properties: { + key: expect.any(Object), + command: expect.any(Object), + }, + }); + }); + + it("writes latest and versioned schema files", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-keybindings-schema-")); + + try { + const result = writeKeybindingsJsonSchemas({ rootDir, version: "1.2.3" }); + expect(result.changed).toBe(true); + + const latestSchema = JSON.parse( + readFileSync(resolve(rootDir, KEYBINDINGS_SCHEMA_RELATIVE_PATH), "utf8"), + ) as Record; + const versionedSchema = JSON.parse( + readFileSync(resolve(rootDir, getVersionedKeybindingsSchemaRelativePath("1.2.3")), "utf8"), + ) as Record; + + expect(latestSchema).toEqual(versionedSchema); + expect(latestSchema.title).toBe("T3 Code Keybindings"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/lib/keybindings-schema.ts b/scripts/lib/keybindings-schema.ts new file mode 100644 index 0000000000..72cb83cb78 --- /dev/null +++ b/scripts/lib/keybindings-schema.ts @@ -0,0 +1,32 @@ +import { KeybindingsConfig } from "@t3tools/contracts"; +import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; + +export const KEYBINDINGS_SCHEMA_RELATIVE_PATH = + "apps/marketing/public/schemas/keybindings.schema.json"; +export const KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH = + "apps/marketing/public/schemas/keybindings"; + +export const getVersionedKeybindingsSchemaRelativePath = (version: string) => + `${KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.schema.json`; + +export function buildKeybindingsJsonSchema(): Record { + return buildJsonSchemaDocument(KeybindingsConfig, { + title: "T3 Code Keybindings", + description: "JSON Schema for the keybindings.json file consumed by T3 Code.", + }); +} + +export function writeKeybindingsJsonSchemas(options?: { + readonly rootDir?: string; + readonly version?: string; +}): { + readonly changed: boolean; +} { + return writeJsonSchemaArtifacts({ + latestRelativePath: KEYBINDINGS_SCHEMA_RELATIVE_PATH, + getVersionedRelativePath: getVersionedKeybindingsSchemaRelativePath, + document: buildKeybindingsJsonSchema(), + ...(options?.rootDir === undefined ? {} : { rootDir: options.rootDir }), + ...(options?.version === undefined ? {} : { version: options.version }), + }); +} diff --git a/scripts/lib/server-settings-schema.ts b/scripts/lib/server-settings-schema.ts index 096135c38a..22caf55f97 100644 --- a/scripts/lib/server-settings-schema.ts +++ b/scripts/lib/server-settings-schema.ts @@ -1,10 +1,5 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; - import { ServerSettings } from "@t3tools/contracts/settings"; -import { toJsonSchemaObject } from "@t3tools/shared/schemaJson"; - -const JSON_SCHEMA_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"; +import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; export const SERVER_SETTINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/server-settings.schema.json"; @@ -15,36 +10,10 @@ export const getVersionedServerSettingsSchemaRelativePath = (version: string) => `${SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.schema.json`; export function buildServerSettingsJsonSchema(): Record { - const schema = toJsonSchemaObject(ServerSettings); - if (!schema || typeof schema !== "object" || Array.isArray(schema)) { - throw new Error("ServerSettings JSON schema must be an object schema."); - } - - return { - $schema: JSON_SCHEMA_DRAFT_2020_12, + return buildJsonSchemaDocument(ServerSettings, { title: "T3 Code Server Settings", description: "JSON Schema for the server-authoritative settings.json file consumed by T3 Code.", - ...schema, - }; -} - -function writeJsonFileIfChanged(filePath: string, document: Record): boolean { - const nextContent = `${JSON.stringify(document, null, 2)}\n`; - const previousContent = (() => { - try { - return readFileSync(filePath, "utf8"); - } catch { - return null; - } - })(); - - if (previousContent === nextContent) { - return false; - } - - mkdirSync(dirname(filePath), { recursive: true }); - writeFileSync(filePath, nextContent); - return true; + }); } export function writeServerSettingsJsonSchemas(options?: { @@ -53,20 +22,11 @@ export function writeServerSettingsJsonSchemas(options?: { }): { readonly changed: boolean; } { - const rootDir = resolve(options?.rootDir ?? process.cwd()); - const latestSchema = buildServerSettingsJsonSchema(); - let changed = writeJsonFileIfChanged( - resolve(rootDir, SERVER_SETTINGS_SCHEMA_RELATIVE_PATH), - latestSchema, - ); - - if (options?.version) { - changed = - writeJsonFileIfChanged( - resolve(rootDir, getVersionedServerSettingsSchemaRelativePath(options.version)), - latestSchema, - ) || changed; - } - - return { changed }; + return writeJsonSchemaArtifacts({ + latestRelativePath: SERVER_SETTINGS_SCHEMA_RELATIVE_PATH, + getVersionedRelativePath: getVersionedServerSettingsSchemaRelativePath, + document: buildServerSettingsJsonSchema(), + ...(options?.rootDir === undefined ? {} : { rootDir: options.rootDir }), + ...(options?.version === undefined ? {} : { version: options.version }), + }); } diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 704ef3c8aa..6e550d3a4a 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -122,6 +122,14 @@ try { tempRoot, "apps/marketing/public/schemas/server-settings/9.9.9-smoke.0.schema.json", ); + const latestKeybindingsSchemaPath = resolve( + tempRoot, + "apps/marketing/public/schemas/keybindings.schema.json", + ); + const versionedKeybindingsSchemaPath = resolve( + tempRoot, + "apps/marketing/public/schemas/keybindings/9.9.9-smoke.0.schema.json", + ); if (!existsSync(latestSchemaPath)) { throw new Error( "Expected release version alignment to generate the latest server settings schema.", @@ -132,12 +140,28 @@ try { "Expected release version alignment to generate the versioned server settings schema.", ); } + if (!existsSync(latestKeybindingsSchemaPath)) { + throw new Error( + "Expected release version alignment to generate the latest keybindings schema.", + ); + } + if (!existsSync(versionedKeybindingsSchemaPath)) { + throw new Error( + "Expected release version alignment to generate the versioned keybindings schema.", + ); + } const versionedSchema = readFileSync(versionedSchemaPath, "utf8"); assertContains( versionedSchema, '"title": "T3 Code Server Settings"', "Expected versioned server settings schema to contain schema metadata.", ); + const versionedKeybindingsSchema = readFileSync(versionedKeybindingsSchemaPath, "utf8"); + assertContains( + versionedKeybindingsSchema, + '"title": "T3 Code Keybindings"', + "Expected versioned keybindings schema to contain schema metadata.", + ); const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); execFileSync( diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 29332f1fc2..43bf7568c7 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -10,7 +10,7 @@ import { } from "./update-release-package-versions"; describe("updateReleasePackageVersions", () => { - it("updates package versions and writes latest plus versioned server settings schemas", () => { + it("updates package versions and writes latest plus versioned config schemas", () => { const rootDir = mkdtempSync(join(tmpdir(), "t3-release-version-bump-")); try { @@ -50,6 +50,24 @@ describe("updateReleasePackageVersions", () => { ), ), ).toMatchObject({ title: "T3 Code Server Settings" }); + + expect( + JSON.parse( + readFileSync( + resolve(rootDir, "apps/marketing/public/schemas/keybindings.schema.json"), + "utf8", + ), + ), + ).toMatchObject({ title: "T3 Code Keybindings" }); + + expect( + JSON.parse( + readFileSync( + resolve(rootDir, "apps/marketing/public/schemas/keybindings/1.2.3.schema.json"), + "utf8", + ), + ), + ).toMatchObject({ title: "T3 Code Keybindings" }); } finally { rmSync(rootDir, { recursive: true, force: true }); } diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index a812f4f17f..785cc32ff7 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -2,6 +2,7 @@ import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { writeKeybindingsJsonSchemas } from "./lib/keybindings-schema.ts"; import { writeServerSettingsJsonSchemas } from "./lib/server-settings-schema.ts"; export const releasePackageFiles = [ @@ -40,6 +41,7 @@ export function updateReleasePackageVersions( } changed = writeServerSettingsJsonSchemas({ rootDir, version }).changed || changed; + changed = writeKeybindingsJsonSchemas({ rootDir, version }).changed || changed; return { changed }; } From 8f5078c6d542b170e27b365f21c05767a6f4ccc6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:27:37 -0700 Subject: [PATCH 04/13] Annotate settings and keybinding schemas with docs - Add schema descriptions for editor tooling and generated JSON schemas - Update published keybindings and server settings schema docs --- KEYBINDINGS.md | 23 ++++ .../public/schemas/keybindings.schema.json | 20 +-- .../schemas/keybindings/0.0.15.schema.json | 20 +-- .../schemas/server-settings.schema.json | 117 ++++++++++++------ .../server-settings/0.0.15.schema.json | 117 ++++++++++++------ packages/contracts/src/keybindings.ts | 52 +++++--- packages/contracts/src/model.ts | 43 ++++++- packages/contracts/src/orchestration.ts | 44 +++++-- packages/contracts/src/settings.ts | 81 +++++++++--- packages/shared/src/schemaJson.test.ts | 26 +++- packages/shared/src/schemaJson.ts | 38 +++++- scripts/lib/json-schema.ts | 60 +++++++++ scripts/lib/keybindings-schema.test.ts | 12 +- scripts/lib/keybindings-schema.ts | 30 ++++- scripts/lib/server-settings-schema.test.ts | 19 ++- scripts/lib/server-settings-schema.ts | 99 ++++++++++++++- 16 files changed, 653 insertions(+), 148 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e7..6957657158 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -15,6 +15,29 @@ The file must be a JSON array of rules: See the full schema for more details: [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts) +## Editor Schema Support + +`keybindings.json` is intentionally a top-level JSON array, so it cannot carry an inline `$schema` property the way `settings.json` can. + +To get autocomplete and hover docs in editors like VS Code, associate the file externally with the published schema: + +```json +{ + "json.schemas": [ + { + "fileMatch": ["**/.t3/keybindings.json", "**/keybindings.json"], + "url": "https://t3.chat/schemas/keybindings.schema.json" + } + ] +} +``` + +If you want a pinned schema instead of the latest stable one, use a versioned URL such as: + +```text +https://t3.chat/schemas/keybindings/0.0.15.schema.json +``` + ## Defaults ```json diff --git a/apps/marketing/public/schemas/keybindings.schema.json b/apps/marketing/public/schemas/keybindings.schema.json index 605380eb49..7f6c849d7f 100644 --- a/apps/marketing/public/schemas/keybindings.schema.json +++ b/apps/marketing/public/schemas/keybindings.schema.json @@ -1,13 +1,14 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "T3 Code Keybindings", - "description": "JSON Schema for the keybindings.json file consumed by T3 Code.", + "description": "Ordered list of custom keybinding rules persisted in `keybindings.json`.", "type": "array", "items": { "type": "object", "properties": { "key": { - "type": "string" + "type": "string", + "description": "Keyboard shortcut to listen for." }, "command": { "anyOf": [ @@ -37,9 +38,11 @@ }, { "type": "string", - "pattern": "^script\\.[\\s\\S]*?\\.run$" + "pattern": "^script\\.[\\s\\S]*?\\.run$", + "description": "Command identifier for running a project script, formatted as `script..run`." } - ] + ], + "description": "Command to execute when the shortcut matches." }, "when": { "anyOf": [ @@ -49,15 +52,18 @@ { "type": "null" } - ] + ], + "description": "Optional expression limiting when the shortcut is active." } }, "required": ["key", "command"], - "additionalProperties": false + "additionalProperties": false, + "description": "Single keybinding rule entry in `keybindings.json`." }, "allOf": [ { - "maxItems": 256 + "maxItems": 256, + "description": "Ordered list of custom keybinding rules persisted in `keybindings.json`." } ] } diff --git a/apps/marketing/public/schemas/keybindings/0.0.15.schema.json b/apps/marketing/public/schemas/keybindings/0.0.15.schema.json index 605380eb49..7f6c849d7f 100644 --- a/apps/marketing/public/schemas/keybindings/0.0.15.schema.json +++ b/apps/marketing/public/schemas/keybindings/0.0.15.schema.json @@ -1,13 +1,14 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "T3 Code Keybindings", - "description": "JSON Schema for the keybindings.json file consumed by T3 Code.", + "description": "Ordered list of custom keybinding rules persisted in `keybindings.json`.", "type": "array", "items": { "type": "object", "properties": { "key": { - "type": "string" + "type": "string", + "description": "Keyboard shortcut to listen for." }, "command": { "anyOf": [ @@ -37,9 +38,11 @@ }, { "type": "string", - "pattern": "^script\\.[\\s\\S]*?\\.run$" + "pattern": "^script\\.[\\s\\S]*?\\.run$", + "description": "Command identifier for running a project script, formatted as `script..run`." } - ] + ], + "description": "Command to execute when the shortcut matches." }, "when": { "anyOf": [ @@ -49,15 +52,18 @@ { "type": "null" } - ] + ], + "description": "Optional expression limiting when the shortcut is active." } }, "required": ["key", "command"], - "additionalProperties": false + "additionalProperties": false, + "description": "Single keybinding rule entry in `keybindings.json`." }, "allOf": [ { - "maxItems": 256 + "maxItems": 256, + "description": "Ordered list of custom keybinding rules persisted in `keybindings.json`." } ] } diff --git a/apps/marketing/public/schemas/server-settings.schema.json b/apps/marketing/public/schemas/server-settings.schema.json index 7726f42718..024a608598 100644 --- a/apps/marketing/public/schemas/server-settings.schema.json +++ b/apps/marketing/public/schemas/server-settings.schema.json @@ -1,29 +1,37 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "T3 Code Server Settings", - "description": "JSON Schema for the server-authoritative settings.json file consumed by T3 Code.", + "description": "Server-authoritative settings persisted in `settings.json`.", "type": "object", "properties": { + "$schema": { + "type": "string", + "description": "Optional JSON Schema reference for editor tooling. May point to the stable or versioned T3 Code settings schema URL." + }, "enableAssistantStreaming": { "anyOf": [ { - "type": "boolean" + "type": "boolean", + "description": "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it." }, { "type": "null" } - ] + ], + "description": "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it." }, "defaultThreadEnvMode": { "anyOf": [ { "type": "string", - "enum": ["local", "worktree"] + "enum": ["local", "worktree"], + "description": "Default execution environment to use when creating new threads." }, { "type": "null" } - ] + ], + "description": "Default execution environment to use when creating new threads." }, "textGenerationModelSelection": { "anyOf": [ @@ -34,10 +42,12 @@ "properties": { "provider": { "type": "string", - "enum": ["codex"] + "enum": ["codex"], + "description": "The provider used for text generation." }, "model": { - "type": "string" + "type": "string", + "description": "The Codex model slug to use for text generation." }, "options": { "type": "object", @@ -51,7 +61,8 @@ { "type": "null" } - ] + ], + "description": "Reasoning depth for Codex text generation. Higher values trade latency and cost for stronger reasoning." }, "fastMode": { "anyOf": [ @@ -61,24 +72,29 @@ { "type": "null" } - ] + ], + "description": "Whether to prefer Codex fast mode when the selected model supports it." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Optional Codex-specific tuning knobs for the selected model." } }, "required": ["provider", "model"], - "additionalProperties": false + "additionalProperties": false, + "description": "Text generation model selection for the Codex provider." }, { "type": "object", "properties": { "provider": { "type": "string", - "enum": ["claudeAgent"] + "enum": ["claudeAgent"], + "description": "The provider used for text generation." }, "model": { - "type": "string" + "type": "string", + "description": "The Claude model slug to use for text generation." }, "options": { "type": "object", @@ -91,7 +107,8 @@ { "type": "null" } - ] + ], + "description": "Whether Claude should enable extended thinking for the selected model." }, "effort": { "anyOf": [ @@ -102,7 +119,8 @@ { "type": "null" } - ] + ], + "description": "Reasoning effort for Claude text generation. Higher values trade latency and cost for stronger reasoning." }, "fastMode": { "anyOf": [ @@ -112,7 +130,8 @@ { "type": "null" } - ] + ], + "description": "Whether to prefer Claude fast mode when the selected model supports it." }, "contextWindow": { "anyOf": [ @@ -122,21 +141,26 @@ { "type": "null" } - ] + ], + "description": "Optional Claude context window preset to request for the selected model, if supported." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Optional Claude-specific tuning knobs for the selected model." } }, "required": ["provider", "model"], - "additionalProperties": false + "additionalProperties": false, + "description": "Text generation model selection for the Claude provider." } - ] + ], + "description": "Default provider and model to use for server-side text generation features." }, { "type": "null" } - ] + ], + "description": "Default provider and model to use for server-side text generation features." }, "providers": { "anyOf": [ @@ -151,17 +175,20 @@ "enabled": { "anyOf": [ { - "type": "boolean" + "type": "boolean", + "description": "Whether the Codex provider is enabled and available for selection." }, { "type": "null" } - ] + ], + "description": "Whether the Codex provider is enabled and available for selection." }, "binaryPath": { "anyOf": [ { - "type": "string" + "type": "string", + "description": "Path to the Codex executable. Leave blank to resolve the `codex` executable from PATH." }, { "type": "null" @@ -171,7 +198,8 @@ "homePath": { "anyOf": [ { - "type": "string" + "type": "string", + "description": "Optional Codex home directory. Leave blank to use the default provider-managed location." }, { "type": "null" @@ -184,20 +212,24 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Additional Codex model slugs to surface in the UI alongside discovered defaults." }, { "type": "null" } - ] + ], + "description": "Additional Codex model slugs to surface in the UI alongside discovered defaults." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Configuration for the Codex provider." }, { "type": "null" } - ] + ], + "description": "Configuration for the Codex provider." }, "claudeAgent": { "anyOf": [ @@ -207,17 +239,20 @@ "enabled": { "anyOf": [ { - "type": "boolean" + "type": "boolean", + "description": "Whether the Claude provider is enabled and available for selection." }, { "type": "null" } - ] + ], + "description": "Whether the Claude provider is enabled and available for selection." }, "binaryPath": { "anyOf": [ { - "type": "string" + "type": "string", + "description": "Path to the Claude executable. Leave blank to resolve the `claude` executable from PATH." }, { "type": "null" @@ -230,28 +265,34 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Additional Claude model slugs to surface in the UI alongside discovered defaults." }, { "type": "null" } - ] + ], + "description": "Additional Claude model slugs to surface in the UI alongside discovered defaults." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Configuration for the Claude provider." }, { "type": "null" } - ] + ], + "description": "Configuration for the Claude provider." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Provider-specific server configuration." }, { "type": "null" } - ] + ], + "description": "Provider-specific server configuration." } }, "additionalProperties": false diff --git a/apps/marketing/public/schemas/server-settings/0.0.15.schema.json b/apps/marketing/public/schemas/server-settings/0.0.15.schema.json index 7726f42718..024a608598 100644 --- a/apps/marketing/public/schemas/server-settings/0.0.15.schema.json +++ b/apps/marketing/public/schemas/server-settings/0.0.15.schema.json @@ -1,29 +1,37 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "T3 Code Server Settings", - "description": "JSON Schema for the server-authoritative settings.json file consumed by T3 Code.", + "description": "Server-authoritative settings persisted in `settings.json`.", "type": "object", "properties": { + "$schema": { + "type": "string", + "description": "Optional JSON Schema reference for editor tooling. May point to the stable or versioned T3 Code settings schema URL." + }, "enableAssistantStreaming": { "anyOf": [ { - "type": "boolean" + "type": "boolean", + "description": "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it." }, { "type": "null" } - ] + ], + "description": "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it." }, "defaultThreadEnvMode": { "anyOf": [ { "type": "string", - "enum": ["local", "worktree"] + "enum": ["local", "worktree"], + "description": "Default execution environment to use when creating new threads." }, { "type": "null" } - ] + ], + "description": "Default execution environment to use when creating new threads." }, "textGenerationModelSelection": { "anyOf": [ @@ -34,10 +42,12 @@ "properties": { "provider": { "type": "string", - "enum": ["codex"] + "enum": ["codex"], + "description": "The provider used for text generation." }, "model": { - "type": "string" + "type": "string", + "description": "The Codex model slug to use for text generation." }, "options": { "type": "object", @@ -51,7 +61,8 @@ { "type": "null" } - ] + ], + "description": "Reasoning depth for Codex text generation. Higher values trade latency and cost for stronger reasoning." }, "fastMode": { "anyOf": [ @@ -61,24 +72,29 @@ { "type": "null" } - ] + ], + "description": "Whether to prefer Codex fast mode when the selected model supports it." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Optional Codex-specific tuning knobs for the selected model." } }, "required": ["provider", "model"], - "additionalProperties": false + "additionalProperties": false, + "description": "Text generation model selection for the Codex provider." }, { "type": "object", "properties": { "provider": { "type": "string", - "enum": ["claudeAgent"] + "enum": ["claudeAgent"], + "description": "The provider used for text generation." }, "model": { - "type": "string" + "type": "string", + "description": "The Claude model slug to use for text generation." }, "options": { "type": "object", @@ -91,7 +107,8 @@ { "type": "null" } - ] + ], + "description": "Whether Claude should enable extended thinking for the selected model." }, "effort": { "anyOf": [ @@ -102,7 +119,8 @@ { "type": "null" } - ] + ], + "description": "Reasoning effort for Claude text generation. Higher values trade latency and cost for stronger reasoning." }, "fastMode": { "anyOf": [ @@ -112,7 +130,8 @@ { "type": "null" } - ] + ], + "description": "Whether to prefer Claude fast mode when the selected model supports it." }, "contextWindow": { "anyOf": [ @@ -122,21 +141,26 @@ { "type": "null" } - ] + ], + "description": "Optional Claude context window preset to request for the selected model, if supported." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Optional Claude-specific tuning knobs for the selected model." } }, "required": ["provider", "model"], - "additionalProperties": false + "additionalProperties": false, + "description": "Text generation model selection for the Claude provider." } - ] + ], + "description": "Default provider and model to use for server-side text generation features." }, { "type": "null" } - ] + ], + "description": "Default provider and model to use for server-side text generation features." }, "providers": { "anyOf": [ @@ -151,17 +175,20 @@ "enabled": { "anyOf": [ { - "type": "boolean" + "type": "boolean", + "description": "Whether the Codex provider is enabled and available for selection." }, { "type": "null" } - ] + ], + "description": "Whether the Codex provider is enabled and available for selection." }, "binaryPath": { "anyOf": [ { - "type": "string" + "type": "string", + "description": "Path to the Codex executable. Leave blank to resolve the `codex` executable from PATH." }, { "type": "null" @@ -171,7 +198,8 @@ "homePath": { "anyOf": [ { - "type": "string" + "type": "string", + "description": "Optional Codex home directory. Leave blank to use the default provider-managed location." }, { "type": "null" @@ -184,20 +212,24 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Additional Codex model slugs to surface in the UI alongside discovered defaults." }, { "type": "null" } - ] + ], + "description": "Additional Codex model slugs to surface in the UI alongside discovered defaults." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Configuration for the Codex provider." }, { "type": "null" } - ] + ], + "description": "Configuration for the Codex provider." }, "claudeAgent": { "anyOf": [ @@ -207,17 +239,20 @@ "enabled": { "anyOf": [ { - "type": "boolean" + "type": "boolean", + "description": "Whether the Claude provider is enabled and available for selection." }, { "type": "null" } - ] + ], + "description": "Whether the Claude provider is enabled and available for selection." }, "binaryPath": { "anyOf": [ { - "type": "string" + "type": "string", + "description": "Path to the Claude executable. Leave blank to resolve the `claude` executable from PATH." }, { "type": "null" @@ -230,28 +265,34 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Additional Claude model slugs to surface in the UI alongside discovered defaults." }, { "type": "null" } - ] + ], + "description": "Additional Claude model slugs to surface in the UI alongside discovered defaults." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Configuration for the Claude provider." }, { "type": "null" } - ] + ], + "description": "Configuration for the Claude provider." } }, - "additionalProperties": false + "additionalProperties": false, + "description": "Provider-specific server configuration." }, { "type": "null" } - ] + ], + "description": "Provider-specific server configuration." } }, "additionalProperties": false diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 067cba8804..ddf1139547 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -46,33 +46,53 @@ export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/), ), Schema.Literal(".run"), -]); +]).annotate({ + description: "Command identifier for running a project script, formatted as `script..run`.", +}); export const KeybindingCommand = Schema.Union([ Schema.Literals(STATIC_KEYBINDING_COMMANDS), SCRIPT_RUN_COMMAND_PATTERN, -]); +]).annotate({ + description: "Command invoked when the keybinding is triggered.", +}); export type KeybindingCommand = typeof KeybindingCommand.Type; -const KeybindingValue = TrimmedString.check( - Schema.isMinLength(1), - Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH), -); +const KeybindingValue = TrimmedString.annotate({ + description: + "Keyboard shortcut string such as `mod+j`, `ctrl+k`, or `shift+space`, using the T3 Code keybinding syntax.", +}).check(Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH)); -const KeybindingWhen = TrimmedString.check( - Schema.isMinLength(1), - Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), -); +const KeybindingWhen = TrimmedString.annotate({ + description: + "Optional context expression controlling when the keybinding is active, such as `terminalFocus` or `!terminalFocus`.", +}).check(Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH)); export const KeybindingRule = Schema.Struct({ - key: KeybindingValue, - command: KeybindingCommand, - when: Schema.optional(KeybindingWhen), + key: KeybindingValue.pipe( + Schema.annotate({ + description: "Keyboard shortcut to listen for.", + }), + ), + command: KeybindingCommand.pipe( + Schema.annotate({ + description: "Command to execute when the shortcut matches.", + }), + ), + when: Schema.optional(KeybindingWhen).pipe( + Schema.annotate({ + description: "Optional expression limiting when the shortcut is active.", + }), + ), +}).annotate({ + description: "Single keybinding rule entry in `keybindings.json`.", }); export type KeybindingRule = typeof KeybindingRule.Type; -export const KeybindingsConfig = Schema.Array(KeybindingRule).check( - Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), -); +export const KeybindingsConfig = Schema.Array(KeybindingRule) + .check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)) + .annotate({ + description: "Ordered list of custom keybinding rules persisted in `keybindings.json`.", + }); export type KeybindingsConfig = typeof KeybindingsConfig.Type; export const KeybindingShortcut = Schema.Struct({ diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index e62a957e05..4e9b306e14 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -9,16 +9,47 @@ export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort; export const CodexModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), - fastMode: Schema.optional(Schema.Boolean), + reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)).pipe( + Schema.annotate({ + description: + "Reasoning depth for Codex text generation. Higher values trade latency and cost for stronger reasoning.", + }), + ), + fastMode: Schema.optional(Schema.Boolean).pipe( + Schema.annotate({ + description: "Whether to prefer Codex fast mode when the selected model supports it.", + }), + ), +}).annotate({ + description: "Provider-specific text generation options for Codex models.", }); export type CodexModelOptions = typeof CodexModelOptions.Type; export const ClaudeModelOptions = Schema.Struct({ - thinking: Schema.optional(Schema.Boolean), - effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), - fastMode: Schema.optional(Schema.Boolean), - contextWindow: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.Boolean).pipe( + Schema.annotate({ + description: "Whether Claude should enable extended thinking for the selected model.", + }), + ), + effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)).pipe( + Schema.annotate({ + description: + "Reasoning effort for Claude text generation. Higher values trade latency and cost for stronger reasoning.", + }), + ), + fastMode: Schema.optional(Schema.Boolean).pipe( + Schema.annotate({ + description: "Whether to prefer Claude fast mode when the selected model supports it.", + }), + ), + contextWindow: Schema.optional(Schema.String).pipe( + Schema.annotate({ + description: + "Optional Claude context window preset to request for the selected model, if supported.", + }), + ), +}).annotate({ + description: "Provider-specific text generation options for Claude models.", }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..2ae65e30d3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -46,20 +46,50 @@ export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ - provider: Schema.Literal("codex"), - model: TrimmedNonEmptyString, - options: Schema.optionalKey(CodexModelOptions), + provider: Schema.Literal("codex").pipe( + Schema.annotate({ + description: "The provider used for text generation.", + }), + ), + model: TrimmedNonEmptyString.pipe( + Schema.annotate({ + description: "The Codex model slug to use for text generation.", + }), + ), + options: Schema.optionalKey(CodexModelOptions).pipe( + Schema.annotate({ + description: "Optional Codex-specific tuning knobs for the selected model.", + }), + ), +}).annotate({ + description: "Text generation model selection for the Codex provider.", }); export type CodexModelSelection = typeof CodexModelSelection.Type; export const ClaudeModelSelection = Schema.Struct({ - provider: Schema.Literal("claudeAgent"), - model: TrimmedNonEmptyString, - options: Schema.optionalKey(ClaudeModelOptions), + provider: Schema.Literal("claudeAgent").pipe( + Schema.annotate({ + description: "The provider used for text generation.", + }), + ), + model: TrimmedNonEmptyString.pipe( + Schema.annotate({ + description: "The Claude model slug to use for text generation.", + }), + ), + options: Schema.optionalKey(ClaudeModelOptions).pipe( + Schema.annotate({ + description: "Optional Claude-specific tuning knobs for the selected model.", + }), + ), +}).annotate({ + description: "Text generation model selection for the Claude provider.", }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]).annotate({ + description: "Selects which provider and model T3 Code should use for text generation.", +}); export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 51fe683f99..f347cf4555 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -41,7 +41,10 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // ── Server Settings (server-authoritative) ──────────────────── -export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); +export const ThreadEnvMode = Schema.Literals(["local", "worktree"]).annotate({ + description: + "Default environment mode for new threads. 'local' runs in the current workspace, while 'worktree' runs in a managed git worktree.", +}); export type ThreadEnvMode = typeof ThreadEnvMode.Type; const makeBinaryPathSetting = (fallback: string) => @@ -57,26 +60,62 @@ const makeBinaryPathSetting = (fallback: string) => ); export const CodexSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), - binaryPath: makeBinaryPathSetting("codex"), - homePath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), - customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), + enabled: Schema.Boolean.annotate({ + description: "Whether the Codex provider is enabled and available for selection.", + }).pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("codex").pipe( + Schema.annotate({ + description: + "Path to the Codex executable. Leave blank to resolve the `codex` executable from PATH.", + }), + ), + homePath: TrimmedString.annotate({ + description: + "Optional Codex home directory. Leave blank to use the default provider-managed location.", + }).pipe(Schema.withDecodingDefault(() => "")), + customModels: Schema.Array(Schema.String) + .annotate({ + description: + "Additional Codex model slugs to surface in the UI alongside discovered defaults.", + }) + .pipe(Schema.withDecodingDefault(() => [])), +}).annotate({ + description: "Server-side configuration for the Codex provider.", }); export type CodexSettings = typeof CodexSettings.Type; export const ClaudeSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), - binaryPath: makeBinaryPathSetting("claude"), - customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), + enabled: Schema.Boolean.annotate({ + description: "Whether the Claude provider is enabled and available for selection.", + }).pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("claude").pipe( + Schema.annotate({ + description: + "Path to the Claude executable. Leave blank to resolve the `claude` executable from PATH.", + }), + ), + customModels: Schema.Array(Schema.String) + .annotate({ + description: + "Additional Claude model slugs to surface in the UI alongside discovered defaults.", + }) + .pipe(Schema.withDecodingDefault(() => [])), +}).annotate({ + description: "Server-side configuration for the Claude provider.", }); export type ClaudeSettings = typeof ClaudeSettings.Type; export const ServerSettings = Schema.Struct({ - enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), - defaultThreadEnvMode: ThreadEnvMode.pipe( - Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode), - ), - textGenerationModelSelection: ModelSelection.pipe( + enableAssistantStreaming: Schema.Boolean.annotate({ + description: + "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it.", + }).pipe(Schema.withDecodingDefault(() => false)), + defaultThreadEnvMode: ThreadEnvMode.annotate({ + description: "Default execution environment to use when creating new threads.", + }).pipe(Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode)), + textGenerationModelSelection: ModelSelection.annotate({ + description: "Default provider and model to use for server-side text generation features.", + }).pipe( Schema.withDecodingDefault(() => ({ provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, @@ -85,9 +124,19 @@ export const ServerSettings = Schema.Struct({ // Provider specific settings providers: Schema.Struct({ - codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), - claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), - }).pipe(Schema.withDecodingDefault(() => ({}))), + codex: CodexSettings.annotate({ + description: "Configuration for the Codex provider.", + }).pipe(Schema.withDecodingDefault(() => ({}))), + claudeAgent: ClaudeSettings.annotate({ + description: "Configuration for the Claude provider.", + }).pipe(Schema.withDecodingDefault(() => ({}))), + }) + .annotate({ + description: "Provider-specific server configuration.", + }) + .pipe(Schema.withDecodingDefault(() => ({}))), +}).annotate({ + description: "Server-authoritative settings persisted in `settings.json`.", }); export type ServerSettings = typeof ServerSettings.Type; diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index fba4cf3d97..1ed9a140c2 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; -import { encodePrettyJsonEffect } from "./schemaJson"; +import { encodePrettyJsonEffect, toJsonSchemaObject } from "./schemaJson"; it.effect("encodePrettyJsonEffect writes indented JSON", () => Effect.gen(function* () { @@ -31,3 +31,27 @@ it.effect("encodePrettyJsonEffect writes indented JSON", () => ); }), ); + +it.effect("toJsonSchemaObject hoists descriptions from wrapper nodes", () => + Effect.sync(() => { + const schema = toJsonSchemaObject( + Schema.Struct({ + enabled: Schema.Boolean.annotate({ + description: "Whether the feature is enabled.", + }).pipe(Schema.withDecodingDefault(() => false)), + name: Schema.String.annotate({ + description: "Human-readable display name.", + }).check(Schema.isMinLength(1)), + }), + ) as Record; + + const properties = schema.properties as Record>; + const enabled = properties.enabled; + const name = properties.name; + + assert.isDefined(enabled); + assert.isDefined(name); + assert.strictEqual(enabled.description, "Whether the feature is enabled."); + assert.strictEqual(name.description, "Human-readable display name."); + }), +); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 010c4eccf6..83c6d9807d 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -57,13 +57,47 @@ export const formatSchemaError = (cause: Cause.Cause) => { : Cause.pretty(cause); }; +function hoistJsonSchemaDescriptions(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(hoistJsonSchemaDescriptions); + } + + if (!value || typeof value !== "object") { + return value; + } + + const record = Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, hoistJsonSchemaDescriptions(entry)]), + ) as Record; + + if (typeof record.description !== "string") { + const candidates = ["allOf", "anyOf", "oneOf"] + .flatMap((key) => (Array.isArray(record[key]) ? (record[key] as ReadonlyArray) : [])) + .filter((candidate): candidate is Record => { + return !!candidate && typeof candidate === "object" && !Array.isArray(candidate); + }); + + const description = candidates.find( + (candidate) => + typeof candidate.description === "string" && + !(candidate.type === "null" && Object.keys(candidate).length <= 1), + )?.description; + + if (typeof description === "string") { + record.description = description; + } + } + + return record; +} + /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ export const toJsonSchemaObject = (schema: Schema.Top): unknown => { const document = Schema.toJsonSchemaDocument(schema); if (document.definitions && Object.keys(document.definitions).length > 0) { - return { ...document.schema, $defs: document.definitions }; + return hoistJsonSchemaDescriptions({ ...document.schema, $defs: document.definitions }); } - return document.schema; + return hoistJsonSchemaDescriptions(document.schema); }; /** diff --git a/scripts/lib/json-schema.ts b/scripts/lib/json-schema.ts index 53fa5408ac..d139948061 100644 --- a/scripts/lib/json-schema.ts +++ b/scripts/lib/json-schema.ts @@ -29,6 +29,66 @@ export function buildJsonSchemaDocument( }; } +export const asJsonSchemaRecord = (value: unknown): Record | null => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + +export const getJsonSchemaProperty = ( + schema: Record, + propertyName: string, +): Record | null => { + const properties = asJsonSchemaRecord(schema.properties); + return properties ? asJsonSchemaRecord(properties[propertyName]) : null; +}; + +export const getNullableJsonSchemaBranch = ( + schema: Record | null, +): Record | null => { + if (!schema) { + return null; + } + + const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : null; + if (!anyOf) { + return schema; + } + + for (const entry of anyOf) { + const branch = asJsonSchemaRecord(entry); + if (!branch || branch.type === "null") { + continue; + } + return branch; + } + + return null; +}; + +export const getJsonSchemaAnyOfBranches = ( + schema: Record | null, +): ReadonlyArray> => { + if (!schema || !Array.isArray(schema.anyOf)) { + return []; + } + + return schema.anyOf + .map(asJsonSchemaRecord) + .filter( + (branch): branch is Record => branch !== null && branch.type !== "null", + ); +}; + +export const setJsonSchemaDescription = ( + schema: Record | null, + description: string, +): void => { + if (!schema) { + return; + } + schema.description = description; +}; + function writeJsonFileIfChanged(filePath: string, document: Record): boolean { const nextContent = `${JSON.stringify(document, null, 2)}\n`; const previousContent = (() => { diff --git a/scripts/lib/keybindings-schema.test.ts b/scripts/lib/keybindings-schema.test.ts index cf7be3d475..656e3a4e6f 100644 --- a/scripts/lib/keybindings-schema.test.ts +++ b/scripts/lib/keybindings-schema.test.ts @@ -19,10 +19,18 @@ describe("buildKeybindingsJsonSchema", () => { expect(schema.title).toBe("T3 Code Keybindings"); expect(schema.type).toBe("array"); expect(schema.items).toMatchObject({ + description: expect.stringContaining("keybinding rule"), type: "object", properties: { - key: expect.any(Object), - command: expect.any(Object), + key: { + description: expect.stringContaining("shortcut"), + }, + command: { + description: expect.stringContaining("execute"), + }, + when: { + description: expect.stringContaining("active"), + }, }, }); }); diff --git a/scripts/lib/keybindings-schema.ts b/scripts/lib/keybindings-schema.ts index 72cb83cb78..84c5743f29 100644 --- a/scripts/lib/keybindings-schema.ts +++ b/scripts/lib/keybindings-schema.ts @@ -1,5 +1,10 @@ import { KeybindingsConfig } from "@t3tools/contracts"; -import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; +import { + buildJsonSchemaDocument, + getJsonSchemaProperty, + setJsonSchemaDescription, + writeJsonSchemaArtifacts, +} from "./json-schema"; export const KEYBINDINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/keybindings.schema.json"; @@ -10,10 +15,31 @@ export const getVersionedKeybindingsSchemaRelativePath = (version: string) => `${KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.schema.json`; export function buildKeybindingsJsonSchema(): Record { - return buildJsonSchemaDocument(KeybindingsConfig, { + const schema = buildJsonSchemaDocument(KeybindingsConfig, { title: "T3 Code Keybindings", description: "JSON Schema for the keybindings.json file consumed by T3 Code.", }); + + const items = + schema.items && typeof schema.items === "object" && !Array.isArray(schema.items) + ? (schema.items as Record) + : null; + + setJsonSchemaDescription(items, "Single keybinding rule entry in `keybindings.json`."); + setJsonSchemaDescription( + getJsonSchemaProperty(items ?? {}, "key"), + "Keyboard shortcut to listen for.", + ); + setJsonSchemaDescription( + getJsonSchemaProperty(items ?? {}, "command"), + "Command to execute when the shortcut matches.", + ); + setJsonSchemaDescription( + getJsonSchemaProperty(items ?? {}, "when"), + "Optional expression limiting when the shortcut is active.", + ); + + return schema; } export function writeKeybindingsJsonSchemas(options?: { diff --git a/scripts/lib/server-settings-schema.test.ts b/scripts/lib/server-settings-schema.test.ts index 0015aa9a08..ca8069a0a6 100644 --- a/scripts/lib/server-settings-schema.test.ts +++ b/scripts/lib/server-settings-schema.test.ts @@ -20,10 +20,21 @@ describe("buildServerSettingsJsonSchema", () => { expect(schema.type).toBe("object"); expect(schema.additionalProperties).toBe(false); expect(schema.properties).toMatchObject({ - enableAssistantStreaming: expect.any(Object), - defaultThreadEnvMode: expect.any(Object), - textGenerationModelSelection: expect.any(Object), - providers: expect.any(Object), + $schema: { + type: "string", + }, + enableAssistantStreaming: { + description: expect.stringContaining("stream"), + }, + defaultThreadEnvMode: { + description: expect.stringContaining("environment"), + }, + textGenerationModelSelection: { + description: expect.stringContaining("provider and model"), + }, + providers: { + description: expect.stringContaining("Provider-specific"), + }, }); }); diff --git a/scripts/lib/server-settings-schema.ts b/scripts/lib/server-settings-schema.ts index 22caf55f97..f82da1382d 100644 --- a/scripts/lib/server-settings-schema.ts +++ b/scripts/lib/server-settings-schema.ts @@ -1,5 +1,12 @@ import { ServerSettings } from "@t3tools/contracts/settings"; -import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; +import { + buildJsonSchemaDocument, + getJsonSchemaAnyOfBranches, + getJsonSchemaProperty, + getNullableJsonSchemaBranch, + setJsonSchemaDescription, + writeJsonSchemaArtifacts, +} from "./json-schema"; export const SERVER_SETTINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/server-settings.schema.json"; @@ -10,10 +17,98 @@ export const getVersionedServerSettingsSchemaRelativePath = (version: string) => `${SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.schema.json`; export function buildServerSettingsJsonSchema(): Record { - return buildJsonSchemaDocument(ServerSettings, { + const schema = buildJsonSchemaDocument(ServerSettings, { title: "T3 Code Server Settings", description: "JSON Schema for the server-authoritative settings.json file consumed by T3 Code.", }); + + const properties = + schema.type === "object" && + schema.properties && + typeof schema.properties === "object" && + !Array.isArray(schema.properties) + ? schema.properties + : null; + + if (!properties) { + throw new Error("ServerSettings JSON schema must expose object properties."); + } + + setJsonSchemaDescription( + getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "enableAssistantStreaming")), + "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it.", + ); + setJsonSchemaDescription( + getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "defaultThreadEnvMode")), + "Default execution environment to use when creating new threads.", + ); + setJsonSchemaDescription( + getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "textGenerationModelSelection")), + "Default provider and model to use for server-side text generation features.", + ); + setJsonSchemaDescription( + getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "providers")), + "Provider-specific server configuration.", + ); + + const providersBranch = getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "providers")); + const codexBranch = getNullableJsonSchemaBranch( + getJsonSchemaProperty(providersBranch ?? {}, "codex"), + ); + const claudeBranch = getNullableJsonSchemaBranch( + getJsonSchemaProperty(providersBranch ?? {}, "claudeAgent"), + ); + + setJsonSchemaDescription(codexBranch, "Configuration for the Codex provider."); + setJsonSchemaDescription( + getNullableJsonSchemaBranch(getJsonSchemaProperty(codexBranch ?? {}, "binaryPath")), + "Path to the Codex executable. Leave blank to resolve the `codex` executable from PATH.", + ); + setJsonSchemaDescription( + getNullableJsonSchemaBranch(getJsonSchemaProperty(codexBranch ?? {}, "homePath")), + "Optional Codex home directory. Leave blank to use the default provider-managed location.", + ); + + setJsonSchemaDescription(claudeBranch, "Configuration for the Claude provider."); + setJsonSchemaDescription( + getNullableJsonSchemaBranch(getJsonSchemaProperty(claudeBranch ?? {}, "binaryPath")), + "Path to the Claude executable. Leave blank to resolve the `claude` executable from PATH.", + ); + + for (const selectionBranch of getJsonSchemaAnyOfBranches( + getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "textGenerationModelSelection")), + )) { + const providerProperty = getJsonSchemaProperty(selectionBranch, "provider"); + const providerBranch = getNullableJsonSchemaBranch(providerProperty); + const providerName = + Array.isArray(providerBranch?.enum) && typeof providerBranch.enum[0] === "string" + ? providerBranch.enum[0] + : null; + + if (providerName === "codex") { + setJsonSchemaDescription( + getJsonSchemaProperty(selectionBranch, "model"), + "The Codex model slug to use for text generation.", + ); + } else if (providerName === "claudeAgent") { + setJsonSchemaDescription( + getJsonSchemaProperty(selectionBranch, "model"), + "The Claude model slug to use for text generation.", + ); + } + } + + return { + ...schema, + properties: { + $schema: { + type: "string", + description: + "Optional JSON Schema reference for editor tooling. May point to the stable or versioned T3 Code settings schema URL.", + }, + ...properties, + }, + }; } export function writeServerSettingsJsonSchemas(options?: { From 68ff6d85783b33ac79557bca9dbed4c1f1e495c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:29:07 -0700 Subject: [PATCH 05/13] Format marketing schema enums consistently - Reflow schema arrays and required lists - Keep published server settings and keybindings schemas normalized --- .../public/schemas/keybindings.schema.json | 5 ++- .../schemas/server-settings.schema.json | 38 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/marketing/public/schemas/keybindings.schema.json b/apps/marketing/public/schemas/keybindings.schema.json index 7f6c849d7f..4d46fa49da 100644 --- a/apps/marketing/public/schemas/keybindings.schema.json +++ b/apps/marketing/public/schemas/keybindings.schema.json @@ -56,7 +56,10 @@ "description": "Optional expression limiting when the shortcut is active." } }, - "required": ["key", "command"], + "required": [ + "key", + "command" + ], "additionalProperties": false, "description": "Single keybinding rule entry in `keybindings.json`." }, diff --git a/apps/marketing/public/schemas/server-settings.schema.json b/apps/marketing/public/schemas/server-settings.schema.json index 024a608598..a7a41c3211 100644 --- a/apps/marketing/public/schemas/server-settings.schema.json +++ b/apps/marketing/public/schemas/server-settings.schema.json @@ -24,7 +24,10 @@ "anyOf": [ { "type": "string", - "enum": ["local", "worktree"], + "enum": [ + "local", + "worktree" + ], "description": "Default execution environment to use when creating new threads." }, { @@ -42,7 +45,9 @@ "properties": { "provider": { "type": "string", - "enum": ["codex"], + "enum": [ + "codex" + ], "description": "The provider used for text generation." }, "model": { @@ -56,7 +61,12 @@ "anyOf": [ { "type": "string", - "enum": ["xhigh", "high", "medium", "low"] + "enum": [ + "xhigh", + "high", + "medium", + "low" + ] }, { "type": "null" @@ -80,7 +90,10 @@ "description": "Optional Codex-specific tuning knobs for the selected model." } }, - "required": ["provider", "model"], + "required": [ + "provider", + "model" + ], "additionalProperties": false, "description": "Text generation model selection for the Codex provider." }, @@ -89,7 +102,9 @@ "properties": { "provider": { "type": "string", - "enum": ["claudeAgent"], + "enum": [ + "claudeAgent" + ], "description": "The provider used for text generation." }, "model": { @@ -114,7 +129,13 @@ "anyOf": [ { "type": "string", - "enum": ["low", "medium", "high", "max", "ultrathink"] + "enum": [ + "low", + "medium", + "high", + "max", + "ultrathink" + ] }, { "type": "null" @@ -149,7 +170,10 @@ "description": "Optional Claude-specific tuning knobs for the selected model." } }, - "required": ["provider", "model"], + "required": [ + "provider", + "model" + ], "additionalProperties": false, "description": "Text generation model selection for the Claude provider." } From 8b9cf23ec770bd1a9d2487ae65d45c0f343d7a44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:33:18 -0700 Subject: [PATCH 06/13] Sync settings descriptions with schema metadata - Centralize settings copy in schema annotations - Reuse schema descriptions in the settings UI - Refresh generated server settings schema output --- .../public/schemas/keybindings.schema.json | 5 +- .../schemas/server-settings.schema.json | 53 +++++--------- .../server-settings/0.0.15.schema.json | 15 ++-- .../components/settings/SettingsPanels.tsx | 70 ++++++++++++++++--- packages/contracts/src/settings.ts | 69 +++++++++--------- packages/shared/src/schemaJson.test.ts | 12 +++- packages/shared/src/schemaJson.ts | 25 +++++++ scripts/lib/server-settings-schema.test.ts | 7 +- 8 files changed, 162 insertions(+), 94 deletions(-) diff --git a/apps/marketing/public/schemas/keybindings.schema.json b/apps/marketing/public/schemas/keybindings.schema.json index 4d46fa49da..7f6c849d7f 100644 --- a/apps/marketing/public/schemas/keybindings.schema.json +++ b/apps/marketing/public/schemas/keybindings.schema.json @@ -56,10 +56,7 @@ "description": "Optional expression limiting when the shortcut is active." } }, - "required": [ - "key", - "command" - ], + "required": ["key", "command"], "additionalProperties": false, "description": "Single keybinding rule entry in `keybindings.json`." }, diff --git a/apps/marketing/public/schemas/server-settings.schema.json b/apps/marketing/public/schemas/server-settings.schema.json index a7a41c3211..f75aaaf878 100644 --- a/apps/marketing/public/schemas/server-settings.schema.json +++ b/apps/marketing/public/schemas/server-settings.schema.json @@ -18,23 +18,20 @@ "type": "null" } ], - "description": "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it." + "description": "Show token-by-token output while a response is in progress." }, "defaultThreadEnvMode": { "anyOf": [ { "type": "string", - "enum": [ - "local", - "worktree" - ], + "enum": ["local", "worktree"], "description": "Default execution environment to use when creating new threads." }, { "type": "null" } ], - "description": "Default execution environment to use when creating new threads." + "description": "Pick the default workspace mode for newly created draft threads." }, "textGenerationModelSelection": { "anyOf": [ @@ -45,9 +42,7 @@ "properties": { "provider": { "type": "string", - "enum": [ - "codex" - ], + "enum": ["codex"], "description": "The provider used for text generation." }, "model": { @@ -61,12 +56,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "xhigh", - "high", - "medium", - "low" - ] + "enum": ["xhigh", "high", "medium", "low"] }, { "type": "null" @@ -90,10 +80,7 @@ "description": "Optional Codex-specific tuning knobs for the selected model." } }, - "required": [ - "provider", - "model" - ], + "required": ["provider", "model"], "additionalProperties": false, "description": "Text generation model selection for the Codex provider." }, @@ -102,9 +89,7 @@ "properties": { "provider": { "type": "string", - "enum": [ - "claudeAgent" - ], + "enum": ["claudeAgent"], "description": "The provider used for text generation." }, "model": { @@ -129,13 +114,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "low", - "medium", - "high", - "max", - "ultrathink" - ] + "enum": ["low", "medium", "high", "max", "ultrathink"] }, { "type": "null" @@ -170,10 +149,7 @@ "description": "Optional Claude-specific tuning knobs for the selected model." } }, - "required": [ - "provider", - "model" - ], + "required": ["provider", "model"], "additionalProperties": false, "description": "Text generation model selection for the Claude provider." } @@ -184,7 +160,7 @@ "type": "null" } ], - "description": "Default provider and model to use for server-side text generation features." + "description": "Configure the model used for generated commit messages, PR titles, and similar Git text." }, "providers": { "anyOf": [ @@ -217,7 +193,8 @@ { "type": "null" } - ] + ], + "description": "Path to the Codex binary" }, "homePath": { "anyOf": [ @@ -228,7 +205,8 @@ { "type": "null" } - ] + ], + "description": "Optional custom Codex home and config directory." }, "customModels": { "anyOf": [ @@ -281,7 +259,8 @@ { "type": "null" } - ] + ], + "description": "Path to the Claude binary" }, "customModels": { "anyOf": [ diff --git a/apps/marketing/public/schemas/server-settings/0.0.15.schema.json b/apps/marketing/public/schemas/server-settings/0.0.15.schema.json index 024a608598..f75aaaf878 100644 --- a/apps/marketing/public/schemas/server-settings/0.0.15.schema.json +++ b/apps/marketing/public/schemas/server-settings/0.0.15.schema.json @@ -18,7 +18,7 @@ "type": "null" } ], - "description": "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it." + "description": "Show token-by-token output while a response is in progress." }, "defaultThreadEnvMode": { "anyOf": [ @@ -31,7 +31,7 @@ "type": "null" } ], - "description": "Default execution environment to use when creating new threads." + "description": "Pick the default workspace mode for newly created draft threads." }, "textGenerationModelSelection": { "anyOf": [ @@ -160,7 +160,7 @@ "type": "null" } ], - "description": "Default provider and model to use for server-side text generation features." + "description": "Configure the model used for generated commit messages, PR titles, and similar Git text." }, "providers": { "anyOf": [ @@ -193,7 +193,8 @@ { "type": "null" } - ] + ], + "description": "Path to the Codex binary" }, "homePath": { "anyOf": [ @@ -204,7 +205,8 @@ { "type": "null" } - ] + ], + "description": "Optional custom Codex home and config directory." }, "customModels": { "anyOf": [ @@ -257,7 +259,8 @@ { "type": "null" } - ] + ], + "description": "Path to the Claude binary" }, "customModels": { "anyOf": [ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b7fde0c5f6..a600a2f688 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -12,14 +12,19 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + ClaudeSettings, + ClientSettingsSchema, + CodexSettings, PROVIDER_DISPLAY_NAMES, type ProviderKind, type ServerProvider, type ServerProviderModel, + ServerSettings, ThreadId, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { getSchemaDescription } from "@t3tools/shared/schemaJson"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; import { @@ -81,6 +86,51 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +function requireSchemaDescription( + schema: Parameters[0], + label: string, +) { + const description = getSchemaDescription(schema); + if (!description) { + throw new Error(`Missing schema description for ${label}`); + } + return description; +} + +const SETTINGS_DESCRIPTIONS = { + timestampFormat: requireSchemaDescription( + ClientSettingsSchema.fields.timestampFormat, + "ClientSettings.timestampFormat", + ), + diffWordWrap: requireSchemaDescription( + ClientSettingsSchema.fields.diffWordWrap, + "ClientSettings.diffWordWrap", + ), + confirmThreadArchive: requireSchemaDescription( + ClientSettingsSchema.fields.confirmThreadArchive, + "ClientSettings.confirmThreadArchive", + ), + confirmThreadDelete: requireSchemaDescription( + ClientSettingsSchema.fields.confirmThreadDelete, + "ClientSettings.confirmThreadDelete", + ), + enableAssistantStreaming: requireSchemaDescription( + ServerSettings.fields.enableAssistantStreaming, + "ServerSettings.enableAssistantStreaming", + ), + defaultThreadEnvMode: requireSchemaDescription( + ServerSettings.fields.defaultThreadEnvMode, + "ServerSettings.defaultThreadEnvMode", + ), + textGenerationModelSelection: requireSchemaDescription( + ServerSettings.fields.textGenerationModelSelection, + "ServerSettings.textGenerationModelSelection", + ), + codexBinaryPath: requireSchemaDescription(CodexSettings.fields.binaryPath, "Codex.binaryPath"), + codexHomePath: requireSchemaDescription(CodexSettings.fields.homePath, "Codex.homePath"), + claudeBinaryPath: requireSchemaDescription(ClaudeSettings.fields.binaryPath, "Claude.binaryPath"), +} as const; + const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; type InstallProviderSettings = { @@ -98,16 +148,16 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ provider: "codex", title: "Codex", binaryPlaceholder: "Codex binary path", - binaryDescription: "Path to the Codex binary", + binaryDescription: SETTINGS_DESCRIPTIONS.codexBinaryPath, homePathKey: "codexHomePath", homePlaceholder: "CODEX_HOME", - homeDescription: "Optional custom Codex home and config directory.", + homeDescription: SETTINGS_DESCRIPTIONS.codexHomePath, }, { provider: "claudeAgent", title: "Claude", binaryPlaceholder: "Claude binary path", - binaryDescription: "Path to the Claude binary", + binaryDescription: SETTINGS_DESCRIPTIONS.claudeBinaryPath, }, ] as const; @@ -774,7 +824,7 @@ export function GeneralSettingsPanel() { + Schema.String.annotate({ description }).pipe( + Schema.decodeTo(TrimmedString, SchemaTransformation.passthrough()), + ); + export const ClientSettingsSchema = Schema.Struct({ - confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), - confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), - diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), - sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( - Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), - ), - sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( - Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), - ), - timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), + confirmThreadArchive: Schema.Boolean.annotate({ + description: "Require a second click on the inline archive action before a thread is archived.", + }).pipe(Schema.withDecodingDefault(() => false)), + confirmThreadDelete: Schema.Boolean.annotate({ + description: "Ask before deleting a thread and its chat history.", + }).pipe(Schema.withDecodingDefault(() => true)), + diffWordWrap: Schema.Boolean.annotate({ + description: "Set the default wrap state when the diff panel opens.", + }).pipe(Schema.withDecodingDefault(() => false)), + sidebarProjectSortOrder: SidebarProjectSortOrder.annotate({ + description: "Choose how projects are ordered in the sidebar.", + }).pipe(Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), + sidebarThreadSortOrder: SidebarThreadSortOrder.annotate({ + description: "Choose how threads are ordered inside the selected project.", + }).pipe(Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), + timestampFormat: TimestampFormat.annotate({ + description: "System default follows your browser or OS clock preference.", + }).pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), +}).annotate({ + description: "Client-only settings persisted locally in the browser.", }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -42,13 +57,12 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // ── Server Settings (server-authoritative) ──────────────────── export const ThreadEnvMode = Schema.Literals(["local", "worktree"]).annotate({ - description: - "Default environment mode for new threads. 'local' runs in the current workspace, while 'worktree' runs in a managed git worktree.", + description: "Pick the default workspace mode for newly created draft threads.", }); export type ThreadEnvMode = typeof ThreadEnvMode.Type; -const makeBinaryPathSetting = (fallback: string) => - TrimmedString.pipe( +const makeBinaryPathSetting = (fallback: string, description: string) => + makeTrimmedStringSetting(description).pipe( Schema.decodeTo( Schema.String, SchemaTransformation.transformOrFail({ @@ -63,16 +77,10 @@ export const CodexSettings = Schema.Struct({ enabled: Schema.Boolean.annotate({ description: "Whether the Codex provider is enabled and available for selection.", }).pipe(Schema.withDecodingDefault(() => true)), - binaryPath: makeBinaryPathSetting("codex").pipe( - Schema.annotate({ - description: - "Path to the Codex executable. Leave blank to resolve the `codex` executable from PATH.", - }), + binaryPath: makeBinaryPathSetting("codex", "Path to the Codex binary"), + homePath: makeTrimmedStringSetting("Optional custom Codex home and config directory.").pipe( + Schema.withDecodingDefault(() => ""), ), - homePath: TrimmedString.annotate({ - description: - "Optional Codex home directory. Leave blank to use the default provider-managed location.", - }).pipe(Schema.withDecodingDefault(() => "")), customModels: Schema.Array(Schema.String) .annotate({ description: @@ -88,12 +96,7 @@ export const ClaudeSettings = Schema.Struct({ enabled: Schema.Boolean.annotate({ description: "Whether the Claude provider is enabled and available for selection.", }).pipe(Schema.withDecodingDefault(() => true)), - binaryPath: makeBinaryPathSetting("claude").pipe( - Schema.annotate({ - description: - "Path to the Claude executable. Leave blank to resolve the `claude` executable from PATH.", - }), - ), + binaryPath: makeBinaryPathSetting("claude", "Path to the Claude binary"), customModels: Schema.Array(Schema.String) .annotate({ description: @@ -107,14 +110,14 @@ export type ClaudeSettings = typeof ClaudeSettings.Type; export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.annotate({ - description: - "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it.", + description: "Show token-by-token output while a response is in progress.", }).pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvMode.annotate({ - description: "Default execution environment to use when creating new threads.", + description: "Pick the default workspace mode for newly created draft threads.", }).pipe(Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode)), textGenerationModelSelection: ModelSelection.annotate({ - description: "Default provider and model to use for server-side text generation features.", + description: + "Configure the model used for generated commit messages, PR titles, and similar Git text.", }).pipe( Schema.withDecodingDefault(() => ({ provider: "codex" as const, diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index 1ed9a140c2..1b635f8938 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; -import { encodePrettyJsonEffect, toJsonSchemaObject } from "./schemaJson"; +import { encodePrettyJsonEffect, getSchemaDescription, toJsonSchemaObject } from "./schemaJson"; it.effect("encodePrettyJsonEffect writes indented JSON", () => Effect.gen(function* () { @@ -55,3 +55,13 @@ it.effect("toJsonSchemaObject hoists descriptions from wrapper nodes", () => assert.strictEqual(name.description, "Human-readable display name."); }), ); + +it.effect("getSchemaDescription reads hoisted descriptions from wrapped schemas", () => + Effect.sync(() => { + const enabled = Schema.Boolean.annotate({ + description: "Whether the feature is enabled.", + }).pipe(Schema.withDecodingDefault(() => false)); + + assert.strictEqual(getSchemaDescription(enabled), "Whether the feature is enabled."); + }), +); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 83c6d9807d..42869ed4b9 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -36,6 +36,8 @@ export const decodeUnknownJsonResult = (); + const PrettyJsonString = SchemaGetter.parseJson().compose( SchemaGetter.stringifyJson({ space: 2 }), ); @@ -100,6 +102,29 @@ export const toJsonSchemaObject = (schema: Schema.Top): unknown => { return hoistJsonSchemaDescriptions(document.schema); }; +export const getSchemaDescription = (schema: Schema.Top): string | undefined => { + if (schema && typeof schema === "object") { + const cached = schemaDescriptionCache.get(schema); + if (cached !== undefined || schemaDescriptionCache.has(schema)) { + return cached; + } + } + + const jsonSchema = toJsonSchemaObject(schema); + const description = + jsonSchema && typeof jsonSchema === "object" && !Array.isArray(jsonSchema) + ? typeof (jsonSchema as Record).description === "string" + ? ((jsonSchema as Record).description as string) + : undefined + : undefined; + + if (schema && typeof schema === "object") { + schemaDescriptionCache.set(schema, description); + } + + return description; +}; + /** * A `Getter` that parses a lenient JSON string (tolerating trailing commas * and JS-style comments) into an unknown value. diff --git a/scripts/lib/server-settings-schema.test.ts b/scripts/lib/server-settings-schema.test.ts index ca8069a0a6..8718661922 100644 --- a/scripts/lib/server-settings-schema.test.ts +++ b/scripts/lib/server-settings-schema.test.ts @@ -24,13 +24,14 @@ describe("buildServerSettingsJsonSchema", () => { type: "string", }, enableAssistantStreaming: { - description: expect.stringContaining("stream"), + description: "Show token-by-token output while a response is in progress.", }, defaultThreadEnvMode: { - description: expect.stringContaining("environment"), + description: "Pick the default workspace mode for newly created draft threads.", }, textGenerationModelSelection: { - description: expect.stringContaining("provider and model"), + description: + "Configure the model used for generated commit messages, PR titles, and similar Git text.", }, providers: { description: expect.stringContaining("Provider-specific"), From aeaf31ff6d786ef974a14137877e66efe24fd9ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:39:49 -0700 Subject: [PATCH 07/13] Rename published schema artifacts to `.json` - Move server settings and keybindings schema URLs to shorter `.json` paths - Update release tooling, docs, and tests to match the new schema locations --- KEYBINDINGS.md | 4 ++-- .../{keybindings.schema.json => keybindings.json} | 0 .../{0.0.15.schema.json => 0.0.15.json} | 0 .../{server-settings.schema.json => settings.json} | 0 .../0.0.15.schema.json => settings/0.0.15.json} | 0 apps/server/src/serverSettings.test.ts | 4 ++-- docs/release.md | 4 ++-- scripts/lib/keybindings-schema.ts | 5 ++--- scripts/lib/server-settings-schema.ts | 7 +++---- scripts/release-smoke.ts | 11 ++++------- scripts/update-release-package-versions.test.ts | 14 ++++---------- 11 files changed, 19 insertions(+), 30 deletions(-) rename apps/marketing/public/schemas/{keybindings.schema.json => keybindings.json} (100%) rename apps/marketing/public/schemas/keybindings/{0.0.15.schema.json => 0.0.15.json} (100%) rename apps/marketing/public/schemas/{server-settings.schema.json => settings.json} (100%) rename apps/marketing/public/schemas/{server-settings/0.0.15.schema.json => settings/0.0.15.json} (100%) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 6957657158..695546fe84 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -26,7 +26,7 @@ To get autocomplete and hover docs in editors like VS Code, associate the file e "json.schemas": [ { "fileMatch": ["**/.t3/keybindings.json", "**/keybindings.json"], - "url": "https://t3.chat/schemas/keybindings.schema.json" + "url": "https://t3.codes/schemas/keybindings.json" } ] } @@ -35,7 +35,7 @@ To get autocomplete and hover docs in editors like VS Code, associate the file e If you want a pinned schema instead of the latest stable one, use a versioned URL such as: ```text -https://t3.chat/schemas/keybindings/0.0.15.schema.json +https://t3.codes/schemas/keybindings/0.0.15.json ``` ## Defaults diff --git a/apps/marketing/public/schemas/keybindings.schema.json b/apps/marketing/public/schemas/keybindings.json similarity index 100% rename from apps/marketing/public/schemas/keybindings.schema.json rename to apps/marketing/public/schemas/keybindings.json diff --git a/apps/marketing/public/schemas/keybindings/0.0.15.schema.json b/apps/marketing/public/schemas/keybindings/0.0.15.json similarity index 100% rename from apps/marketing/public/schemas/keybindings/0.0.15.schema.json rename to apps/marketing/public/schemas/keybindings/0.0.15.json diff --git a/apps/marketing/public/schemas/server-settings.schema.json b/apps/marketing/public/schemas/settings.json similarity index 100% rename from apps/marketing/public/schemas/server-settings.schema.json rename to apps/marketing/public/schemas/settings.json diff --git a/apps/marketing/public/schemas/server-settings/0.0.15.schema.json b/apps/marketing/public/schemas/settings/0.0.15.json similarity index 100% rename from apps/marketing/public/schemas/server-settings/0.0.15.schema.json rename to apps/marketing/public/schemas/settings/0.0.15.json diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 232286aa14..6226c28162 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -226,7 +226,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverConfig.settingsPath, `${JSON.stringify( { - $schema: "https://t3.chat/schemas/server-settings/0.0.15.schema.json", + $schema: "https://t3.codes/schemas/settings/0.0.15.json", providers: { codex: { binaryPath: "/usr/local/bin/codex", @@ -250,7 +250,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.deepEqual(JSON.parse(raw), { - $schema: "https://t3.chat/schemas/server-settings/0.0.15.schema.json", + $schema: "https://t3.codes/schemas/settings/0.0.15.json", providers: { codex: { binaryPath: "/usr/local/bin/codex", diff --git a/docs/release.md b/docs/release.md index cd890c2cf0..ad0cc10e14 100644 --- a/docs/release.md +++ b/docs/release.md @@ -17,8 +17,8 @@ This document covers how to run desktop releases from one tag, first without sig - Includes Electron auto-update metadata (for example `latest*.yml` and `*.blockmap`) in release assets. - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing. - Signing is optional and auto-detected per platform from secrets. -- Refreshes the marketing-site JSON Schemas for `settings.json` and `keybindings.json` at `/schemas/server-settings.schema.json` and `/schemas/keybindings.schema.json`. -- Commits immutable versioned JSON Schema copies at `/schemas/server-settings/.schema.json` and `/schemas/keybindings/.schema.json`. +- Refreshes the marketing-site JSON Schemas for `settings.json` and `keybindings.json` at `/schemas/settings.json` and `/schemas/keybindings.json`. +- Commits immutable versioned JSON Schema copies at `/schemas/settings/.json` and `/schemas/keybindings/.json`. ## Desktop auto-update notes diff --git a/scripts/lib/keybindings-schema.ts b/scripts/lib/keybindings-schema.ts index 84c5743f29..0995506d06 100644 --- a/scripts/lib/keybindings-schema.ts +++ b/scripts/lib/keybindings-schema.ts @@ -6,13 +6,12 @@ import { writeJsonSchemaArtifacts, } from "./json-schema"; -export const KEYBINDINGS_SCHEMA_RELATIVE_PATH = - "apps/marketing/public/schemas/keybindings.schema.json"; +export const KEYBINDINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/keybindings.json"; export const KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH = "apps/marketing/public/schemas/keybindings"; export const getVersionedKeybindingsSchemaRelativePath = (version: string) => - `${KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.schema.json`; + `${KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.json`; export function buildKeybindingsJsonSchema(): Record { const schema = buildJsonSchemaDocument(KeybindingsConfig, { diff --git a/scripts/lib/server-settings-schema.ts b/scripts/lib/server-settings-schema.ts index f82da1382d..b1407ee8f8 100644 --- a/scripts/lib/server-settings-schema.ts +++ b/scripts/lib/server-settings-schema.ts @@ -8,13 +8,12 @@ import { writeJsonSchemaArtifacts, } from "./json-schema"; -export const SERVER_SETTINGS_SCHEMA_RELATIVE_PATH = - "apps/marketing/public/schemas/server-settings.schema.json"; +export const SERVER_SETTINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/settings.json"; export const SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH = - "apps/marketing/public/schemas/server-settings"; + "apps/marketing/public/schemas/settings"; export const getVersionedServerSettingsSchemaRelativePath = (version: string) => - `${SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.schema.json`; + `${SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.json`; export function buildServerSettingsJsonSchema(): Record { const schema = buildJsonSchemaDocument(ServerSettings, { diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 6e550d3a4a..4f0a94db0e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -114,21 +114,18 @@ try { "Expected bun.lock to contain the smoke version.", ); - const latestSchemaPath = resolve( - tempRoot, - "apps/marketing/public/schemas/server-settings.schema.json", - ); + const latestSchemaPath = resolve(tempRoot, "apps/marketing/public/schemas/settings.json"); const versionedSchemaPath = resolve( tempRoot, - "apps/marketing/public/schemas/server-settings/9.9.9-smoke.0.schema.json", + "apps/marketing/public/schemas/settings/9.9.9-smoke.0.json", ); const latestKeybindingsSchemaPath = resolve( tempRoot, - "apps/marketing/public/schemas/keybindings.schema.json", + "apps/marketing/public/schemas/keybindings.json", ); const versionedKeybindingsSchemaPath = resolve( tempRoot, - "apps/marketing/public/schemas/keybindings/9.9.9-smoke.0.schema.json", + "apps/marketing/public/schemas/keybindings/9.9.9-smoke.0.json", ); if (!existsSync(latestSchemaPath)) { throw new Error( diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 43bf7568c7..39a7e2e4b5 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -35,17 +35,14 @@ describe("updateReleasePackageVersions", () => { expect( JSON.parse( - readFileSync( - resolve(rootDir, "apps/marketing/public/schemas/server-settings.schema.json"), - "utf8", - ), + readFileSync(resolve(rootDir, "apps/marketing/public/schemas/settings.json"), "utf8"), ), ).toMatchObject({ title: "T3 Code Server Settings" }); expect( JSON.parse( readFileSync( - resolve(rootDir, "apps/marketing/public/schemas/server-settings/1.2.3.schema.json"), + resolve(rootDir, "apps/marketing/public/schemas/settings/1.2.3.json"), "utf8", ), ), @@ -53,17 +50,14 @@ describe("updateReleasePackageVersions", () => { expect( JSON.parse( - readFileSync( - resolve(rootDir, "apps/marketing/public/schemas/keybindings.schema.json"), - "utf8", - ), + readFileSync(resolve(rootDir, "apps/marketing/public/schemas/keybindings.json"), "utf8"), ), ).toMatchObject({ title: "T3 Code Keybindings" }); expect( JSON.parse( readFileSync( - resolve(rootDir, "apps/marketing/public/schemas/keybindings/1.2.3.schema.json"), + resolve(rootDir, "apps/marketing/public/schemas/keybindings/1.2.3.json"), "utf8", ), ), From 80475e854ca85f40b936b965ae53fcccea02614c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:44:56 -0700 Subject: [PATCH 08/13] Skip unchanged versioned schema writes - Avoid writing versioned schema snapshots when the latest schema content did not change - Add coverage for keybindings, server settings, and release version updates --- scripts/lib/json-schema.ts | 5 +-- scripts/lib/keybindings-schema.test.ts | 19 ++++++++++- scripts/lib/server-settings-schema.test.ts | 19 ++++++++++- .../update-release-package-versions.test.ts | 34 ++++++++++++++++++- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/scripts/lib/json-schema.ts b/scripts/lib/json-schema.ts index d139948061..c4323247c5 100644 --- a/scripts/lib/json-schema.ts +++ b/scripts/lib/json-schema.ts @@ -118,12 +118,13 @@ export function writeJsonSchemaArtifacts(options: { readonly changed: boolean; } { const rootDir = resolve(options.rootDir ?? process.cwd()); - let changed = writeJsonFileIfChanged( + const latestChanged = writeJsonFileIfChanged( resolve(rootDir, options.latestRelativePath), options.document, ); + let changed = latestChanged; - if (options.version) { + if (options.version && latestChanged) { changed = writeJsonFileIfChanged( resolve(rootDir, options.getVersionedRelativePath(options.version)), diff --git a/scripts/lib/keybindings-schema.test.ts b/scripts/lib/keybindings-schema.test.ts index 656e3a4e6f..c0c928e962 100644 --- a/scripts/lib/keybindings-schema.test.ts +++ b/scripts/lib/keybindings-schema.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; @@ -55,4 +55,21 @@ describe("buildKeybindingsJsonSchema", () => { rmSync(rootDir, { recursive: true, force: true }); } }); + + it("skips writing a versioned schema file when the latest schema is unchanged", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-keybindings-schema-")); + + try { + const latestOnly = writeKeybindingsJsonSchemas({ rootDir }); + expect(latestOnly.changed).toBe(true); + + const result = writeKeybindingsJsonSchemas({ rootDir, version: "1.2.3" }); + expect(result.changed).toBe(false); + expect(existsSync(resolve(rootDir, getVersionedKeybindingsSchemaRelativePath("1.2.3")))).toBe( + false, + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/lib/server-settings-schema.test.ts b/scripts/lib/server-settings-schema.test.ts index 8718661922..3ff1469980 100644 --- a/scripts/lib/server-settings-schema.test.ts +++ b/scripts/lib/server-settings-schema.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; @@ -62,4 +62,21 @@ describe("buildServerSettingsJsonSchema", () => { rmSync(rootDir, { recursive: true, force: true }); } }); + + it("skips writing a versioned schema file when the latest schema is unchanged", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-server-settings-schema-")); + + try { + const latestOnly = writeServerSettingsJsonSchemas({ rootDir }); + expect(latestOnly.changed).toBe(true); + + const result = writeServerSettingsJsonSchemas({ rootDir, version: "1.2.3" }); + expect(result.changed).toBe(false); + expect( + existsSync(resolve(rootDir, getVersionedServerSettingsSchemaRelativePath("1.2.3"))), + ).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 39a7e2e4b5..c60909ae10 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -8,6 +8,8 @@ import { releasePackageFiles, updateReleasePackageVersions, } from "./update-release-package-versions"; +import { writeKeybindingsJsonSchemas } from "./lib/keybindings-schema"; +import { writeServerSettingsJsonSchemas } from "./lib/server-settings-schema"; describe("updateReleasePackageVersions", () => { it("updates package versions and writes latest plus versioned config schemas", () => { @@ -66,4 +68,34 @@ describe("updateReleasePackageVersions", () => { rmSync(rootDir, { recursive: true, force: true }); } }); + + it("skips versioned schema snapshots when the latest schemas are unchanged", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-release-version-bump-")); + + try { + for (const relativePath of releasePackageFiles) { + const filePath = resolve(rootDir, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync( + filePath, + `${JSON.stringify({ name: relativePath, version: "1.2.3" }, null, 2)}\n`, + ); + } + + writeServerSettingsJsonSchemas({ rootDir }); + writeKeybindingsJsonSchemas({ rootDir }); + + const result = updateReleasePackageVersions("1.2.3", { rootDir }); + expect(result.changed).toBe(false); + + expect( + existsSync(resolve(rootDir, "apps/marketing/public/schemas/settings/1.2.3.json")), + ).toBe(false); + expect( + existsSync(resolve(rootDir, "apps/marketing/public/schemas/keybindings/1.2.3.json")), + ).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); }); From c3097ba5d7b17754fa18941d46f57bd6b6ec4966 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 22:31:33 -0700 Subject: [PATCH 09/13] Move schema descriptions into contracts - Inline settings and keybinding schema descriptions in shared contracts - Simplify schema generation scripts to emit the updated docs --- apps/marketing/public/schemas/settings.json | 12 ++-- packages/contracts/src/keybindings.ts | 2 + packages/contracts/src/orchestration.ts | 4 ++ scripts/lib/keybindings-schema.ts | 30 +-------- scripts/lib/server-settings-schema.ts | 73 +-------------------- 5 files changed, 15 insertions(+), 106 deletions(-) diff --git a/apps/marketing/public/schemas/settings.json b/apps/marketing/public/schemas/settings.json index f75aaaf878..463c66bac5 100644 --- a/apps/marketing/public/schemas/settings.json +++ b/apps/marketing/public/schemas/settings.json @@ -12,7 +12,7 @@ "anyOf": [ { "type": "boolean", - "description": "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it." + "description": "Show token-by-token output while a response is in progress." }, { "type": "null" @@ -25,7 +25,7 @@ { "type": "string", "enum": ["local", "worktree"], - "description": "Default execution environment to use when creating new threads." + "description": "Pick the default workspace mode for newly created draft threads." }, { "type": "null" @@ -154,7 +154,7 @@ "description": "Text generation model selection for the Claude provider." } ], - "description": "Default provider and model to use for server-side text generation features." + "description": "Configure the model used for generated commit messages, PR titles, and similar Git text." }, { "type": "null" @@ -188,7 +188,7 @@ "anyOf": [ { "type": "string", - "description": "Path to the Codex executable. Leave blank to resolve the `codex` executable from PATH." + "description": "Path to the Codex binary" }, { "type": "null" @@ -200,7 +200,7 @@ "anyOf": [ { "type": "string", - "description": "Optional Codex home directory. Leave blank to use the default provider-managed location." + "description": "Optional custom Codex home and config directory." }, { "type": "null" @@ -254,7 +254,7 @@ "anyOf": [ { "type": "string", - "description": "Path to the Claude executable. Leave blank to resolve the `claude` executable from PATH." + "description": "Path to the Claude binary" }, { "type": "null" diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index ddf1139547..6006a608b1 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -69,9 +69,11 @@ const KeybindingWhen = TrimmedString.annotate({ }).check(Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH)); export const KeybindingRule = Schema.Struct({ key: KeybindingValue.pipe( + Schema.flip, Schema.annotate({ description: "Keyboard shortcut to listen for.", }), + Schema.flip, ), command: KeybindingCommand.pipe( Schema.annotate({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2ae65e30d3..6a57d76079 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -52,9 +52,11 @@ export const CodexModelSelection = Schema.Struct({ }), ), model: TrimmedNonEmptyString.pipe( + Schema.flip, Schema.annotate({ description: "The Codex model slug to use for text generation.", }), + Schema.flip, ), options: Schema.optionalKey(CodexModelOptions).pipe( Schema.annotate({ @@ -73,9 +75,11 @@ export const ClaudeModelSelection = Schema.Struct({ }), ), model: TrimmedNonEmptyString.pipe( + Schema.flip, Schema.annotate({ description: "The Claude model slug to use for text generation.", }), + Schema.flip, ), options: Schema.optionalKey(ClaudeModelOptions).pipe( Schema.annotate({ diff --git a/scripts/lib/keybindings-schema.ts b/scripts/lib/keybindings-schema.ts index 0995506d06..deba37d4fe 100644 --- a/scripts/lib/keybindings-schema.ts +++ b/scripts/lib/keybindings-schema.ts @@ -1,10 +1,5 @@ import { KeybindingsConfig } from "@t3tools/contracts"; -import { - buildJsonSchemaDocument, - getJsonSchemaProperty, - setJsonSchemaDescription, - writeJsonSchemaArtifacts, -} from "./json-schema"; +import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; export const KEYBINDINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/keybindings.json"; export const KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH = @@ -14,31 +9,10 @@ export const getVersionedKeybindingsSchemaRelativePath = (version: string) => `${KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.json`; export function buildKeybindingsJsonSchema(): Record { - const schema = buildJsonSchemaDocument(KeybindingsConfig, { + return buildJsonSchemaDocument(KeybindingsConfig, { title: "T3 Code Keybindings", description: "JSON Schema for the keybindings.json file consumed by T3 Code.", }); - - const items = - schema.items && typeof schema.items === "object" && !Array.isArray(schema.items) - ? (schema.items as Record) - : null; - - setJsonSchemaDescription(items, "Single keybinding rule entry in `keybindings.json`."); - setJsonSchemaDescription( - getJsonSchemaProperty(items ?? {}, "key"), - "Keyboard shortcut to listen for.", - ); - setJsonSchemaDescription( - getJsonSchemaProperty(items ?? {}, "command"), - "Command to execute when the shortcut matches.", - ); - setJsonSchemaDescription( - getJsonSchemaProperty(items ?? {}, "when"), - "Optional expression limiting when the shortcut is active.", - ); - - return schema; } export function writeKeybindingsJsonSchemas(options?: { diff --git a/scripts/lib/server-settings-schema.ts b/scripts/lib/server-settings-schema.ts index b1407ee8f8..3804ec0d25 100644 --- a/scripts/lib/server-settings-schema.ts +++ b/scripts/lib/server-settings-schema.ts @@ -1,12 +1,5 @@ import { ServerSettings } from "@t3tools/contracts/settings"; -import { - buildJsonSchemaDocument, - getJsonSchemaAnyOfBranches, - getJsonSchemaProperty, - getNullableJsonSchemaBranch, - setJsonSchemaDescription, - writeJsonSchemaArtifacts, -} from "./json-schema"; +import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; export const SERVER_SETTINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/settings.json"; export const SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH = @@ -33,70 +26,6 @@ export function buildServerSettingsJsonSchema(): Record { throw new Error("ServerSettings JSON schema must expose object properties."); } - setJsonSchemaDescription( - getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "enableAssistantStreaming")), - "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it.", - ); - setJsonSchemaDescription( - getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "defaultThreadEnvMode")), - "Default execution environment to use when creating new threads.", - ); - setJsonSchemaDescription( - getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "textGenerationModelSelection")), - "Default provider and model to use for server-side text generation features.", - ); - setJsonSchemaDescription( - getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "providers")), - "Provider-specific server configuration.", - ); - - const providersBranch = getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "providers")); - const codexBranch = getNullableJsonSchemaBranch( - getJsonSchemaProperty(providersBranch ?? {}, "codex"), - ); - const claudeBranch = getNullableJsonSchemaBranch( - getJsonSchemaProperty(providersBranch ?? {}, "claudeAgent"), - ); - - setJsonSchemaDescription(codexBranch, "Configuration for the Codex provider."); - setJsonSchemaDescription( - getNullableJsonSchemaBranch(getJsonSchemaProperty(codexBranch ?? {}, "binaryPath")), - "Path to the Codex executable. Leave blank to resolve the `codex` executable from PATH.", - ); - setJsonSchemaDescription( - getNullableJsonSchemaBranch(getJsonSchemaProperty(codexBranch ?? {}, "homePath")), - "Optional Codex home directory. Leave blank to use the default provider-managed location.", - ); - - setJsonSchemaDescription(claudeBranch, "Configuration for the Claude provider."); - setJsonSchemaDescription( - getNullableJsonSchemaBranch(getJsonSchemaProperty(claudeBranch ?? {}, "binaryPath")), - "Path to the Claude executable. Leave blank to resolve the `claude` executable from PATH.", - ); - - for (const selectionBranch of getJsonSchemaAnyOfBranches( - getNullableJsonSchemaBranch(getJsonSchemaProperty(schema, "textGenerationModelSelection")), - )) { - const providerProperty = getJsonSchemaProperty(selectionBranch, "provider"); - const providerBranch = getNullableJsonSchemaBranch(providerProperty); - const providerName = - Array.isArray(providerBranch?.enum) && typeof providerBranch.enum[0] === "string" - ? providerBranch.enum[0] - : null; - - if (providerName === "codex") { - setJsonSchemaDescription( - getJsonSchemaProperty(selectionBranch, "model"), - "The Codex model slug to use for text generation.", - ); - } else if (providerName === "claudeAgent") { - setJsonSchemaDescription( - getJsonSchemaProperty(selectionBranch, "model"), - "The Claude model slug to use for text generation.", - ); - } - } - return { ...schema, properties: { From a43922f5c9269a987102d649a56ab3d1302d7a10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 23:31:13 -0700 Subject: [PATCH 10/13] Remove unused JSON schema helpers - Delete dead helper exports from `scripts/lib/json-schema.ts` - Keep schema document generation focused on file output --- scripts/lib/json-schema.ts | 60 -------------------------------------- 1 file changed, 60 deletions(-) diff --git a/scripts/lib/json-schema.ts b/scripts/lib/json-schema.ts index c4323247c5..ec7209a368 100644 --- a/scripts/lib/json-schema.ts +++ b/scripts/lib/json-schema.ts @@ -29,66 +29,6 @@ export function buildJsonSchemaDocument( }; } -export const asJsonSchemaRecord = (value: unknown): Record | null => - value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; - -export const getJsonSchemaProperty = ( - schema: Record, - propertyName: string, -): Record | null => { - const properties = asJsonSchemaRecord(schema.properties); - return properties ? asJsonSchemaRecord(properties[propertyName]) : null; -}; - -export const getNullableJsonSchemaBranch = ( - schema: Record | null, -): Record | null => { - if (!schema) { - return null; - } - - const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : null; - if (!anyOf) { - return schema; - } - - for (const entry of anyOf) { - const branch = asJsonSchemaRecord(entry); - if (!branch || branch.type === "null") { - continue; - } - return branch; - } - - return null; -}; - -export const getJsonSchemaAnyOfBranches = ( - schema: Record | null, -): ReadonlyArray> => { - if (!schema || !Array.isArray(schema.anyOf)) { - return []; - } - - return schema.anyOf - .map(asJsonSchemaRecord) - .filter( - (branch): branch is Record => branch !== null && branch.type !== "null", - ); -}; - -export const setJsonSchemaDescription = ( - schema: Record | null, - description: string, -): void => { - if (!schema) { - return; - } - schema.description = description; -}; - function writeJsonFileIfChanged(filePath: string, document: Record): boolean { const nextContent = `${JSON.stringify(document, null, 2)}\n`; const previousContent = (() => { From 9d475dd3254730fa8dc66cfd58b689af7c56e80c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 23:31:37 -0700 Subject: [PATCH 11/13] Format marketing JSON schemas - Reformat keybinding and settings schema enums - Normalize required field arrays --- .../marketing/public/schemas/keybindings.json | 5 ++- apps/marketing/public/schemas/settings.json | 38 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/marketing/public/schemas/keybindings.json b/apps/marketing/public/schemas/keybindings.json index 7f6c849d7f..4d46fa49da 100644 --- a/apps/marketing/public/schemas/keybindings.json +++ b/apps/marketing/public/schemas/keybindings.json @@ -56,7 +56,10 @@ "description": "Optional expression limiting when the shortcut is active." } }, - "required": ["key", "command"], + "required": [ + "key", + "command" + ], "additionalProperties": false, "description": "Single keybinding rule entry in `keybindings.json`." }, diff --git a/apps/marketing/public/schemas/settings.json b/apps/marketing/public/schemas/settings.json index 463c66bac5..6c6e2025e9 100644 --- a/apps/marketing/public/schemas/settings.json +++ b/apps/marketing/public/schemas/settings.json @@ -24,7 +24,10 @@ "anyOf": [ { "type": "string", - "enum": ["local", "worktree"], + "enum": [ + "local", + "worktree" + ], "description": "Pick the default workspace mode for newly created draft threads." }, { @@ -42,7 +45,9 @@ "properties": { "provider": { "type": "string", - "enum": ["codex"], + "enum": [ + "codex" + ], "description": "The provider used for text generation." }, "model": { @@ -56,7 +61,12 @@ "anyOf": [ { "type": "string", - "enum": ["xhigh", "high", "medium", "low"] + "enum": [ + "xhigh", + "high", + "medium", + "low" + ] }, { "type": "null" @@ -80,7 +90,10 @@ "description": "Optional Codex-specific tuning knobs for the selected model." } }, - "required": ["provider", "model"], + "required": [ + "provider", + "model" + ], "additionalProperties": false, "description": "Text generation model selection for the Codex provider." }, @@ -89,7 +102,9 @@ "properties": { "provider": { "type": "string", - "enum": ["claudeAgent"], + "enum": [ + "claudeAgent" + ], "description": "The provider used for text generation." }, "model": { @@ -114,7 +129,13 @@ "anyOf": [ { "type": "string", - "enum": ["low", "medium", "high", "max", "ultrathink"] + "enum": [ + "low", + "medium", + "high", + "max", + "ultrathink" + ] }, { "type": "null" @@ -149,7 +170,10 @@ "description": "Optional Claude-specific tuning knobs for the selected model." } }, - "required": ["provider", "model"], + "required": [ + "provider", + "model" + ], "additionalProperties": false, "description": "Text generation model selection for the Claude provider." } From 56f56f55067167f6520f9ef1a434388038763612 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 23:44:55 -0700 Subject: [PATCH 12/13] Export keybindings contract for schema generation - Add a dedicated `@t3tools/contracts/keybindings` export - Point keybindings schema generation at the new subpath - Keep generated schema output under `apps/marketing/public/schemas` --- .oxfmtrc.json | 3 ++- packages/contracts/package.json | 5 +++++ scripts/lib/keybindings-schema.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ef2236d0f2..482eebc459 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,7 +8,8 @@ "bun.lock", "*.tsbuildinfo", "**/routeTree.gen.ts", - "apps/web/public/mockServiceWorker.js" + "apps/web/public/mockServiceWorker.js", + "apps/marketing/public/schemas" ], "sortPackageJson": {} } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index fe03c205a5..476a21f688 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,6 +10,11 @@ "module": "./dist/index.mjs", "types": "./src/index.ts", "exports": { + "./keybindings": { + "types": "./src/keybindings.ts", + "import": "./src/keybindings.ts", + "require": "./src/keybindings.ts" + }, "./settings": { "types": "./src/settings.ts", "import": "./src/settings.ts", diff --git a/scripts/lib/keybindings-schema.ts b/scripts/lib/keybindings-schema.ts index deba37d4fe..a8c496505d 100644 --- a/scripts/lib/keybindings-schema.ts +++ b/scripts/lib/keybindings-schema.ts @@ -1,4 +1,4 @@ -import { KeybindingsConfig } from "@t3tools/contracts"; +import { KeybindingsConfig } from "@t3tools/contracts/keybindings"; import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; export const KEYBINDINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/keybindings.json"; From c13fc4f6f8d9e0533d3605806a864cf5bd2188f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 23:51:27 -0700 Subject: [PATCH 13/13] Inline keybindings schema source for scripts - Import keybindings types directly from contracts source - Narrow TS includes to the schema generation inputs --- apps/server/tsconfig.json | 9 ++++++++- packages/contracts/package.json | 5 ----- scripts/lib/keybindings-schema.ts | 2 +- scripts/tsconfig.json | 6 +++++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 07d52467f5..6f2e8a0bdf 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -21,5 +21,12 @@ } ] }, - "include": ["src", "tsdown.config.ts", "scripts", "integration", "../../scripts/lib"] + "include": [ + "src", + "tsdown.config.ts", + "scripts", + "integration", + "../../scripts/lib/brand-assets.ts", + "../../scripts/lib/resolve-catalog.ts" + ] } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 476a21f688..fe03c205a5 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,11 +10,6 @@ "module": "./dist/index.mjs", "types": "./src/index.ts", "exports": { - "./keybindings": { - "types": "./src/keybindings.ts", - "import": "./src/keybindings.ts", - "require": "./src/keybindings.ts" - }, "./settings": { "types": "./src/settings.ts", "import": "./src/settings.ts", diff --git a/scripts/lib/keybindings-schema.ts b/scripts/lib/keybindings-schema.ts index a8c496505d..202c8dc767 100644 --- a/scripts/lib/keybindings-schema.ts +++ b/scripts/lib/keybindings-schema.ts @@ -1,4 +1,4 @@ -import { KeybindingsConfig } from "@t3tools/contracts/keybindings"; +import { KeybindingsConfig } from "../../packages/contracts/src/keybindings.ts"; import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; export const KEYBINDINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/keybindings.json"; diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index e9ed7c8ae5..45c7e1b994 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -12,5 +12,9 @@ } ] }, - "include": ["**/*.ts"] + "include": [ + "**/*.ts", + "../packages/contracts/src/baseSchemas.ts", + "../packages/contracts/src/keybindings.ts" + ] }