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
8export const T3_PROJECT_FILE_NAME = "t3.json";
9
10export const T3_PROJECT_FILE_SCHEMA_URL = "https://t3.codes/schema/t3.json";
11
12const T3_PROJECT_FILE_PATH_MAX_LENGTH = 512;
13const T3_PROJECT_FILE_MAX_SCRIPTS = 50;
14
15// Encoded-side annotations survive into JSON Schema; decoding still trims and rejects empty strings.
16const trimmedNonEmpty = (annotations: { readonly description: string }, maxLength?: number) => {
17 const annotated = Schema.String.annotate(annotations);
18 const encoded =
19 maxLength === undefined
20 ? annotated.check(Schema.isNonEmpty())
21 : annotated.check(Schema.isNonEmpty(), Schema.isMaxLength(maxLength));
22 return encoded.pipe(Schema.decodeTo(encoded, SchemaTransformation.trim()));
23};
24
25export const T3ProjectFileScript = Schema.Struct({
26 name: trimmedNonEmpty({
27 description: "Display name for the script, shown in the T3 Code scripts menu.",
28 }),
29 command: trimmedNonEmpty({
30 description: "Shell command executed in a T3 Code terminal at the project root.",
31 }),
32 icon: Schema.optionalKey(
33 ProjectScriptIcon.annotate({
34 description: 'Icon shown next to the script in the scripts menu. Defaults to "play".',
35 }),
36 ),
37 runOnWorktreeCreate: Schema.optionalKey(
38 Schema.Boolean.annotate({
39 description:
40 "When true, the script runs automatically after a worktree is created for a new thread.",
41 }),
42 ),
43 runOnSettle: Schema.optionalKey(
44 Schema.Boolean.annotate({
45 description:
46 "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.",
47 }),
48 ),
49 stopOnSettle: Schema.optionalKey(
50 Schema.Boolean.annotate({
51 description:
52 "When true, T3 stops this action's terminal and child processes when its thread settles.",
53 }),
54 ),
55 async: Schema.optionalKey(
56 Schema.Boolean.annotate({
57 description:
58 "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.",
59 }),
60 ),
61 previewUrl: Schema.optionalKey(
62 trimmedNonEmpty({
63 description:
64 "URL opened in the in-app browser preview when this script runs. Only honored on the desktop build.",
65 }),
66 ),
67 autoOpenPreview: Schema.optionalKey(
68 Schema.Boolean.annotate({
69 description:
70 "When true, automatically open the preview panel at `previewUrl` the moment the script starts.",
71 }),
72 ),
73}).annotate({
74 description: "A project script that team members can import into T3 Code.",
75});
76export type T3ProjectFileScript = typeof T3ProjectFileScript.Type;
77
78export const T3ProjectFile = Schema.Struct({
79 $schema: Schema.optionalKey(
80 Schema.String.annotate({
81 description: `URL of the JSON Schema for this file, typically "${T3_PROJECT_FILE_SCHEMA_URL}".`,
82 }),
83 ),
84 iconPath: Schema.optionalKey(
85 trimmedNonEmpty(
86 {
87 description:
88 'Workspace-relative path to the project icon (e.g. "assets/logo.svg"). Checked before T3 Code\'s built-in icon locations.',
89 },
90 T3_PROJECT_FILE_PATH_MAX_LENGTH,
91 ),
92 ),
93 defaultThreadEnvMode: Schema.optionalKey(
94 ThreadEnvMode.annotate({
95 description:
96 '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.',
97 }),
98 ),
99 worktreeSubmodules: Schema.optionalKey(
100 WorktreeSubmodules.annotate({
101 description:
102 '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.',
103 }),
104 ),
105 scripts: Schema.optionalKey(
106 Schema.Array(T3ProjectFileScript)
107 .annotate({
108 description: "Project scripts shared with everyone who opens this repository in T3 Code.",
109 })
110 .check(Schema.isMaxLength(T3_PROJECT_FILE_MAX_SCRIPTS)),
111 ),
112}).annotate({
113 title: "T3 project file",
114 description:
115 "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.",
116});
117export type T3ProjectFile = typeof T3ProjectFile.Type;
118
119/** Keys must be nullable in ServerSettings so both environment and project overrides can inherit t3.json. */
120export const PROJECT_FILE_BACKED_SETTINGS = {
121 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },
122 worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" },
123} as const satisfies {
124 // providerInstances overrides individual fields, so it cannot use whole-setting file inheritance.
125 readonly [K in ProjectScopedServerSettingKey & keyof ServerSettings]?: {
126 readonly field: {
127 readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends
128 | Exclude<ServerSettings[K], null>
129 | undefined
130 ? F
131 : never;
132 }[keyof T3ProjectFile];
133 readonly builtIn: Exclude<ServerSettings[K], null>;
134 };
135};
136export type ProjectFileBackedSettingKey = keyof typeof PROJECT_FILE_BACKED_SETTINGS;
137
138export type ResolvedServerSettings = Omit<ServerSettings, ProjectFileBackedSettingKey> & {
139 readonly [K in ProjectFileBackedSettingKey]: Exclude<ServerSettings[K], null>;
140};