1import * as Schema from "effect/Schema";
2import * as SchemaTransformation from "effect/SchemaTransformation";
3
4import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts";
5import { ProjectScriptIcon } from "./project.ts";
6import type { ProjectScopedServerSettingKey, ServerSettings } from "./settings.ts";
7
8/** File name of the checked-in T3 project file, resolved at the workspace root. */
9export const T3_PROJECT_FILE_NAME = "t3.json";
10
11/** Public URL of the published JSON Schema for {@link T3ProjectFile}. */
12export const T3_PROJECT_FILE_SCHEMA_URL = "https://t3.codes/schema/t3.json";
13
14const T3_PROJECT_FILE_PATH_MAX_LENGTH = 512;
15const T3_PROJECT_FILE_MAX_SCRIPTS = 50;
16
17// Annotations go on the encoded (string) side so they survive into the
18// published JSON Schema; decoding still trims and re-validates non-emptiness.
19const trimmedNonEmpty = (annotations: { readonly description: string }, maxLength?: number) => {
20 const annotated = Schema.String.annotate(annotations);
21 const encoded =
22 maxLength === undefined
23 ? annotated.check(Schema.isNonEmpty())
24 : annotated.check(Schema.isNonEmpty(), Schema.isMaxLength(maxLength));
25 return encoded.pipe(Schema.decodeTo(encoded, SchemaTransformation.trim()));
26};
27
28export const T3ProjectFileScript = Schema.Struct({
29 name: trimmedNonEmpty({
30 description: "Display name for the script, shown in the T3 Code scripts menu.",
31 }),
32 command: trimmedNonEmpty({
33 description: "Shell command executed in a T3 Code terminal at the project root.",
34 }),
35 icon: Schema.optionalKey(
36 ProjectScriptIcon.annotate({
37 description: 'Icon shown next to the script in the scripts menu. Defaults to "play".',
38 }),
39 ),
40 runOnWorktreeCreate: Schema.optionalKey(
41 Schema.Boolean.annotate({
42 description:
43 "When true, the script runs automatically after a worktree is created for a new thread.",
44 }),
45 ),
46 runOnSettle: Schema.optionalKey(
47 Schema.Boolean.annotate({
48 description:
49 "When true, the script runs in the thread's worktree each time the thread settles, for example to delete build output. Threads without their own worktree skip it.",
50 }),
51 ),
52 async: Schema.optionalKey(
53 Schema.Boolean.annotate({
54 description:
55 "Only for runOnWorktreeCreate scripts. When true (the default), the agent starts while the script is still running. Set false to hold the agent until the script exits.",
56 }),
57 ),
58 previewUrl: Schema.optionalKey(
59 trimmedNonEmpty({
60 description:
61 "URL opened in the in-app browser preview when this script runs. Only honored on the desktop build.",
62 }),
63 ),
64 autoOpenPreview: Schema.optionalKey(
65 Schema.Boolean.annotate({
66 description:
67 "When true, automatically open the preview panel at `previewUrl` the moment the script starts.",
68 }),
69 ),
70}).annotate({
71 description: "A project script that team members can import into T3 Code.",
72});
73export type T3ProjectFileScript = typeof T3ProjectFileScript.Type;
74
75export const T3ProjectFile = Schema.Struct({
76 $schema: Schema.optionalKey(
77 Schema.String.annotate({
78 description: `URL of the JSON Schema for this file, typically "${T3_PROJECT_FILE_SCHEMA_URL}".`,
79 }),
80 ),
81 iconPath: Schema.optionalKey(
82 trimmedNonEmpty(
83 {
84 description:
85 'Workspace-relative path to the project icon (e.g. "assets/logo.svg"). Checked before T3 Code\'s built-in icon locations.',
86 },
87 T3_PROJECT_FILE_PATH_MAX_LENGTH,
88 ),
89 ),
90 defaultThreadEnvMode: Schema.optionalKey(
91 ThreadEnvMode.annotate({
92 description:
93 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.',
94 }),
95 ),
96 worktreeSubmodules: Schema.optionalKey(
97 WorktreeSubmodules.annotate({
98 description:
99 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle. A project or environment setting in T3 Code overrides this.',
100 }),
101 ),
102 scripts: Schema.optionalKey(
103 Schema.Array(T3ProjectFileScript)
104 .annotate({
105 description: "Project scripts shared with everyone who opens this repository in T3 Code.",
106 })
107 .check(Schema.isMaxLength(T3_PROJECT_FILE_MAX_SCRIPTS)),
108 ),
109}).annotate({
110 title: "T3 project file",
111 description:
112 "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.",
113});
114export type T3ProjectFile = typeof T3ProjectFile.Type;
115
116/**
117 * Settings a repository can also declare in t3.json. A key here must be
118 * nullable on `ServerSettings` (null means inherit) so both the project
119 * override and the environment value can defer to the file; `field` names
120 * the t3.json field carrying the same value and `builtIn` is what applies
121 * when every tier is unset. `resolveProjectSettings` walks project override,
122 * environment value, file, built-in, so listing a key here is the whole
123 * change for a new file-backed setting.
124 */
125export const PROJECT_FILE_BACKED_SETTINGS = {
126 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },
127 worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" },
128} as const satisfies {
129 // `providerInstances` is scoped per project but overrides field by field
130 // rather than replacing a whole `ServerSettings` value (see its doc comment
131 // in settings.ts), so it cannot be file-backed and is excluded here.
132 readonly [K in ProjectScopedServerSettingKey & keyof ServerSettings]?: {
133 readonly field: {
134 readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends
135 | Exclude<ServerSettings[K], null>
136 | undefined
137 ? F
138 : never;
139 }[keyof T3ProjectFile];
140 readonly builtIn: Exclude<ServerSettings[K], null>;
141 };
142};
143export type ProjectFileBackedSettingKey = keyof typeof PROJECT_FILE_BACKED_SETTINGS;
144
145/**
146 * `ServerSettings` with every file-backed key resolved to a concrete value.
147 * What `resolveProjectSettings(...).settings` produces once a t3.json (or
148 * its absence) has been accounted for.
149 */
150export type ResolvedServerSettings = Omit<ServerSettings, ProjectFileBackedSettingKey> & {
151 readonly [K in ProjectFileBackedSettingKey]: Exclude<ServerSettings[K], null>;
152};