1import { tokenizeCliArgs } from "@t3tools/shared/cliArgs";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4
5import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession";
6import {
7 PI_T3_MCP_EXTENSION_FILENAME,
8 PI_T3_MCP_EXTENSION_SOURCE,
9 T3_MCP_BEARER_ENV,
10 T3_MCP_URL_ENV,
11 T3_PI_RUNTIME_MODE_ENV,
12 T3_PI_MCP_EXTENSION_PATH_ENV,
13} from "./mcpExtensionSource.ts";
14
15const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([
16 "--continue",
17 "-c",
18 "--export",
19 "--fork",
20 "--help",
21 "-h",
22 "--list-models",
23 "--mode",
24 "--no-session",
25 "--print",
26 "-p",
27 "--resume",
28 "-r",
29 "--session",
30 "--session-id",
31 "--version",
32 "-v",
33]);
34
35const PI_ARGUMENTS_WITH_VALUES = new Set([
36 "--api-key",
37 "--append-system-prompt",
38 "--exclude-tools",
39 "-xt",
40 "--extension",
41 "-e",
42 "--model",
43 "--models",
44 "--name",
45 "-n",
46 "--prompt-template",
47 "--provider",
48 "--session-dir",
49 "--skill",
50 "--system-prompt",
51 "--theme",
52 "--thinking",
53 "--tools",
54 "-t",
55 "--tui-mode",
56 "--use-theme",
57]);
58
59const PI_ARGUMENTS_WITHOUT_VALUES = new Set([
60 "--approve",
61 "-a",
62 "--no-approve",
63 "-na",
64 "--no-builtin-tools",
65 "-nbt",
66 "--no-context-files",
67 "-nc",
68 "--no-extensions",
69 "-ne",
70 "--no-prompt-templates",
71 "-np",
72 "--no-skills",
73 "-ns",
74 "--no-themes",
75 "--no-tools",
76 "-nt",
77 "--offline",
78 "--verbose",
79]);
80
81export type PiLaunchArgsResolution =
82 | { readonly ok: true; readonly args: ReadonlyArray<string> }
83 | { readonly ok: false; readonly message: string };
84
85function reservedPiArgument(arg: string): string | undefined {
86 for (const reserved of RESERVED_PI_LAUNCH_ARGUMENTS) {
87 if (arg === reserved || (reserved.startsWith("--") && arg.startsWith(`${reserved}=`))) {
88 return reserved;
89 }
90 }
91 return undefined;
92}
93
94function normalizePiBuiltInEqualsArguments(args: ReadonlyArray<string>): ReadonlyArray<string> {
95 return args.flatMap((arg) => {
96 const equalsIndex = arg.indexOf("=");
97 if (equalsIndex <= 0) return [arg];
98 const option = arg.slice(0, equalsIndex);
99 return PI_ARGUMENTS_WITH_VALUES.has(option) ? [option, arg.slice(equalsIndex + 1)] : [arg];
100 });
101}
102
103/**
104 * Pi launch arguments may configure resources, models, tools, trust, and
105 * storage. T3 owns RPC mode and session identity, so arguments that select a
106 * different execution mode or native session are rejected before spawn.
107 */
108export function resolvePiLaunchArgs(launchArgs: string): PiLaunchArgsResolution {
109 // Pi parses equals-form tokens only as extension flags, even when their name
110 // matches a built-in option. Split known built-ins while leaving arbitrary
111 // extension flags in their native form.
112 const args = normalizePiBuiltInEqualsArguments(tokenizeCliArgs(launchArgs));
113 let hasProvider = false;
114 let hasModel = false;
115 for (let index = 0; index < args.length; index += 1) {
116 const arg = args[index];
117 if (arg === undefined) continue;
118 const reserved = reservedPiArgument(arg);
119 if (reserved !== undefined) {
120 return {
121 ok: false,
122 message: `Pi launch argument '${reserved}' is controlled by T3 Code and cannot be overridden.`,
123 };
124 }
125 if (arg === "--") {
126 return {
127 ok: false,
128 message: "Pi launch arguments cannot include positional prompts.",
129 };
130 }
131 if (PI_ARGUMENTS_WITH_VALUES.has(arg)) {
132 const value = args[index + 1];
133 if (value === undefined) {
134 return { ok: false, message: `Pi launch argument '${arg}' requires a value.` };
135 }
136 if (arg === "--provider") hasProvider = true;
137 if (arg === "--model") hasModel = true;
138 index += 1;
139 continue;
140 }
141 if (PI_ARGUMENTS_WITHOUT_VALUES.has(arg) || (arg.startsWith("--") && arg.includes("="))) {
142 continue;
143 }
144 if (arg.startsWith("--")) {
145 // Pi extensions may register arbitrary long flags. Treat one following
146 // non-flag token as that extension flag's value.
147 if (
148 args[index + 1] !== undefined &&
149 !args[index + 1]!.startsWith("-") &&
150 !args[index + 1]!.startsWith("@")
151 ) {
152 index += 1;
153 }
154 continue;
155 }
156 if (arg.startsWith("-")) {
157 return { ok: false, message: `Pi launch argument '${arg}' is not supported by T3 Code.` };
158 }
159 return {
160 ok: false,
161 message: `Pi launch arguments cannot include positional prompt '${arg}'.`,
162 };
163 }
164 // Pi 1.0 exits at startup on `--provider` without `--model`; older versions
165 // silently ran another provider's default model. Rejecting it here makes
166 // every spawn site report the reason instead of a bare exit code.
167 if (hasProvider && !hasModel) {
168 return { ok: false, message: "Pi launch argument '--provider' requires '--model'." };
169 }
170 return { ok: true, args };
171}
172
173function bearerTokenFromAuthorizationHeader(header: string): string {
174 return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header;
175}
176
177function normalizedPiPath(value: string): string {
178 return value.replace(/\\/g, "/").replace(/\/+$/, "");
179}
180
181function hasExplicitExtension(args: ReadonlyArray<string>, extensionPath: string): boolean {
182 const wanted = normalizedPiPath(extensionPath);
183 for (let index = 0; index < args.length; index += 1) {
184 const arg = args[index];
185 if (arg !== "--extension" && arg !== "-e") continue;
186 const configured = args[index + 1];
187 if (configured !== undefined && normalizedPiPath(configured) === wanted) return true;
188 index += 1;
189 }
190 return false;
191}
192
193function withoutExplicitExtensions(args: ReadonlyArray<string>): ReadonlyArray<string> {
194 const filtered: string[] = [];
195 for (let index = 0; index < args.length; index += 1) {
196 const arg = args[index];
197 if (arg === undefined) continue;
198 if (arg === "--extension" || arg === "-e") {
199 index += 1;
200 continue;
201 }
202 if (arg.startsWith("--extension=") || arg.startsWith("-e=")) continue;
203 filtered.push(arg);
204 }
205 return filtered;
206}
207
208function withoutToolSelectionArgs(args: ReadonlyArray<string>): ReadonlyArray<string> {
209 const filtered: string[] = [];
210 for (let index = 0; index < args.length; index += 1) {
211 const arg = args[index];
212 if (arg === undefined) continue;
213 if (arg === "--tools" || arg === "-t" || arg === "--exclude-tools" || arg === "-xt") {
214 index += 1;
215 continue;
216 }
217 if (
218 arg === "--no-tools" ||
219 arg === "-nt" ||
220 arg === "--no-builtin-tools" ||
221 arg === "-nbt" ||
222 arg.startsWith("--tools=") ||
223 arg.startsWith("-t=") ||
224 arg.startsWith("--exclude-tools=") ||
225 arg.startsWith("-xt=")
226 ) {
227 continue;
228 }
229 filtered.push(arg);
230 }
231 return filtered;
232}
233
234function piT3McpExtensionDestPath(cacheDir: string): string {
235 return `${cacheDir.replace(/\\/g, "/")}/${PI_T3_MCP_EXTENSION_FILENAME}`;
236}
237
238export const materializePiT3McpExtension = Effect.fn("materializePiT3McpExtension")(function* (
239 cacheDir: string,
240) {
241 const fs = yield* FileSystem.FileSystem;
242 yield* fs.makeDirectory(cacheDir, { recursive: true });
243 const dest = piT3McpExtensionDestPath(cacheDir);
244 const existing = yield* fs.readFileString(dest).pipe(Effect.orElseSucceed(() => ""));
245 if (existing !== PI_T3_MCP_EXTENSION_SOURCE) {
246 yield* fs.writeFileString(dest, PI_T3_MCP_EXTENSION_SOURCE);
247 }
248 return dest;
249});
250
251export function buildPiRpcLaunch(input: {
252 readonly launchArgs: ReadonlyArray<string>;
253 readonly environment: NodeJS.ProcessEnv;
254 readonly mcpSession: McpProviderSessionConfig | undefined;
255 readonly extensionPath: string | undefined;
256 readonly ephemeral?: boolean;
257 readonly disableExtensions?: boolean;
258 readonly disableTools?: boolean;
259 readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "auto" | "full-access";
260}): {
261 readonly args: ReadonlyArray<string>;
262 readonly env: NodeJS.ProcessEnv;
263 readonly hasT3Mcp: boolean;
264} {
265 const hasT3Extension = input.disableExtensions !== true && input.extensionPath !== undefined;
266 const hasT3Mcp = hasT3Extension && input.mcpSession !== undefined;
267 const extensionSafeArgs =
268 input.disableExtensions === true
269 ? withoutExplicitExtensions(input.launchArgs)
270 : input.launchArgs;
271 const launchArgs =
272 input.disableTools === true ? withoutToolSelectionArgs(extensionSafeArgs) : extensionSafeArgs;
273 const args = [
274 "--mode",
275 "rpc",
276 ...(input.ephemeral === true ? ["--no-session"] : []),
277 ...launchArgs,
278 // Restrictions follow user launch args so a configured --tools or
279 // --extension cannot silently re-enable unattended text-generation code.
280 ...(input.disableExtensions === true ? ["--no-extensions"] : []),
281 ...(input.disableTools === true ? ["--no-tools"] : []),
282 ];
283 if (
284 hasT3Extension &&
285 input.extensionPath !== undefined &&
286 !hasExplicitExtension(args, input.extensionPath)
287 ) {
288 args.push("--extension", input.extensionPath);
289 }
290 const environment = { ...input.environment };
291 // These values belong to the current T3 session. Never let a Pi child reuse
292 // credentials inherited from the server or a parent provider process.
293 delete environment[T3_MCP_URL_ENV];
294 delete environment[T3_MCP_BEARER_ENV];
295 delete environment[T3_PI_MCP_EXTENSION_PATH_ENV];
296
297 return {
298 args,
299 env: {
300 ...environment,
301 ...(hasT3Extension && input.extensionPath !== undefined
302 ? { [T3_PI_MCP_EXTENSION_PATH_ENV]: input.extensionPath }
303 : {}),
304 ...(hasT3Extension && input.runtimeMode !== undefined
305 ? {
306 [T3_PI_RUNTIME_MODE_ENV]:
307 input.runtimeMode === "auto" ? "approval-required" : input.runtimeMode,
308 }
309 : {}),
310 ...(hasT3Mcp && input.mcpSession !== undefined
311 ? {
312 [T3_MCP_URL_ENV]: input.mcpSession.endpoint,
313 [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader(
314 input.mcpSession.authorizationHeader,
315 ),
316 }
317 : {}),
318 },
319 hasT3Mcp,
320 };
321}